chore: Move the deprecated GET /workflows/{id}/{versionId} alias to the decorator controller and delete the legacy workflows handler (#40340)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Sam Wooler <swooler592@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
n8n-cat-bot[bot] 2026-10-06 12:26:56 +00:00 committed by GitHub
parent e5c8a8bb62
commit 0bd0cd2873
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 477 additions and 162 deletions

View File

@ -526,7 +526,9 @@ export {
workflowVersionListItemPublicSchema,
} from './workflow-history/workflow-version-history-public.dto';
export {
DeprecatedWorkflowVersionPublicDto,
WorkflowVersionPublicDto,
deprecatedWorkflowVersionPublicSchema,
workflowVersionPublicSchema,
} from './workflow-history/workflow-version-public.dto';

View File

@ -7,3 +7,15 @@ export const workflowVersionPublicSchema = activeWorkflowVersionPublicSchema.omi
});
export class WorkflowVersionPublicDto extends Z.class(workflowVersionPublicSchema.shape) {}
export const deprecatedWorkflowVersionPublicSchema = workflowVersionPublicSchema.partial({
nodeGroups: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
});
export class DeprecatedWorkflowVersionPublicDto extends Z.class(
deprecatedWorkflowVersionPublicSchema.shape,
) {}

View File

@ -163,7 +163,6 @@ export default defineConfig(
files: [
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
],
rules: {
'n8n-local-rules/require-public-api-controller': 'off',

View File

@ -157,7 +157,6 @@ export default defineConfig({
'./src/public-api/v1/handlers/evaluations/evaluations.handler.ts',
'./src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts',
'./src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts',
'./src/public-api/v1/handlers/workflows/workflows.handler.ts',
],
rules: {
'n8n-local-rules/require-public-api-controller': 'off',

View File

@ -19,16 +19,6 @@ export type PaginatedRequest = AuthenticatedRequest<
lastId?: string;
}
>;
export declare namespace WorkflowRequest {
type Activate = AuthenticatedRequest<
{ id: string },
{},
{ versionId?: string; name?: string; description?: string },
{}
>;
type GetVersion = AuthenticatedRequest<{ id: string; versionId: string }, {}, {}, {}>;
}
export declare namespace PackageRequest {
type Import = AuthenticatedRequest<
{},

View File

@ -3,6 +3,7 @@ import {
CreatedWorkflowPublicDto,
CreateWorkflowPublicDto,
DeletedWorkflowPublicDto,
DeprecatedWorkflowVersionPublicDto,
GetWorkflowQueryDto,
ListWorkflowHistoryQueryDto,
ListWorkflowsQueryDto,
@ -75,7 +76,8 @@ import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-hi
import { WorkflowService } from '@/workflows/workflow.service';
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
const DEPRECATED_ALIAS_SINCE = new Date('2026-07-23T00:00:00Z');
const ACTIVATE_DEACTIVATE_DEPRECATED_SINCE = new Date('2026-07-23T00:00:00Z');
const OLD_VERSION_PATH_DEPRECATED_SINCE = new Date('2026-08-26T00:00:00Z');
const UPDATE_CONFLICT_DESCRIPTION =
'Conflict, e.g. re-publication blocked by an open workflow review (then `reason` and ' +
@ -650,7 +652,7 @@ export class WorkflowsPublicController {
}
@Post('/:workflowId/activate')
@Deprecated({ since: DEPRECATED_ALIAS_SINCE })
@Deprecated({ since: ACTIVATE_DEACTIVATE_DEPRECATED_SINCE })
@ApiKeyScope('workflow:activate')
@ProjectScope('workflow:publish')
@ApiSummary('Publish a workflow')
@ -675,7 +677,7 @@ export class WorkflowsPublicController {
}
@Post('/:workflowId/deactivate')
@Deprecated({ since: DEPRECATED_ALIAS_SINCE })
@Deprecated({ since: ACTIVATE_DEACTIVATE_DEPRECATED_SINCE })
@ApiKeyScope('workflow:deactivate')
@ProjectScope('workflow:unpublish')
@ApiSummary('Deactivate a workflow')
@ -831,4 +833,44 @@ export class WorkflowsPublicController {
return tags.map(toPublicTag);
}
/**
* Keep this route as the last registered route in the controller, so that it does not shadow other routes.
*/
@Get('/:workflowId/:workflowVersionId')
@Deprecated({ since: OLD_VERSION_PATH_DEPRECATED_SINCE })
@ApiKeyScope('workflow:read')
@ProjectScope('workflow:read')
@ApiSummary('Retrieves a specific version of a workflow')
@ApiDescription(
'Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId} instead. ' +
'Retrieves a specific version of a workflow from workflow history.',
)
@ApiTags(['Workflow'])
@ApiResponse(200, DeprecatedWorkflowVersionPublicDto)
@ApiErrorResponse(404)
async getDeprecatedWorkflowVersion(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId', workflowIdParamSchema) workflowId: string,
@Param('workflowVersionId', workflowVersionIdParamSchema) workflowVersionId: string,
): Promise<DeprecatedWorkflowVersionPublicDto> {
try {
const version = await this.workflowHistoryService.getVersion(
req.user,
workflowId,
workflowVersionId,
{ includePublishHistory: false },
);
this.eventService.emit('user-retrieved-workflow-version', {
userId: req.user.id,
publicApi: true,
});
return toPublicWorkflowVersion(version);
} catch (error) {
throw new NotFoundError('Version not found');
}
}
}

View File

@ -0,0 +1,181 @@
operationId: getDeprecatedWorkflowVersion
tags:
- Workflow
summary: Retrieves a specific version of a workflow
description: 'Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId} instead. Retrieves a specific version of a workflow from workflow history.'
x-required-scope: workflow:read
deprecated: true
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 workflow.
name: workflowId
in: path
- schema:
type: string
required: true
description: The ID of the workflow version.
name: workflowVersionId
in: path
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
properties:
versionId:
type: string
workflowId:
type: string
nodes:
type: array
description: Nodes that make up the workflow
items:
type: object
properties:
id:
type: string
example: 0f5532f9-36ba-4bef-86c7-30d607400b15
name:
type: string
example: Jira
webhookId:
type: string
disabled:
type: boolean
notesInFlow:
type: boolean
notes:
type: string
type:
type: string
example: n8n-nodes-base.jira
typeVersion:
type: number
example: 1
executeOnce:
type: boolean
example: false
alwaysOutputData:
type: boolean
example: false
retryOnFail:
type: boolean
example: false
maxTries:
type: number
waitBetweenTries:
type: number
continueOnFail:
type: boolean
example: false
description: use onError instead
deprecated: true
onError:
type: string
example: stopWorkflow
position:
type: array
items:
type: number
example:
- -100
- 80
parameters:
type: object
additionalProperties: true
credentials:
type: object
example:
jiraSoftwareCloudApi:
name: jiraApi
customTelemetryTags:
type: object
properties:
tag:
type: array
items:
type: object
properties:
key:
type: string
value:
type: string
required:
- key
- value
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
connections:
type: object
description: Connections between nodes, keyed by source node name
example:
Jira:
main:
- - node: Jira
type: main
index: 0
nodeGroups:
type: array
description: Visual groupings of nodes shown as frames on the canvas
items:
type: object
properties:
id:
type: string
description: Unique identifier for the node group
example: 9b1c8e2a-4d3f-4a6b-8c7d-1e2f3a4b5c6d
name:
type: string
description: Display name of the node group
example: Data processing
description:
type: string
maxLength: 155
description: Optional plain-text description of the node group
example: Cleans and normalizes incoming records
nodeIds:
type: array
description: IDs of the nodes that belong to this group
items:
type: string
required:
- id
- name
- nodeIds
authors:
type: string
name:
type: string
nullable: true
description:
type: string
nullable: true
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
required:
- versionId
- workflowId
- nodes
- connections
- authors
'401':
$ref: ../../../../shared/spec/responses/unauthorized.yml
'403':
$ref: ../../../../shared/spec/responses/forbidden.yml
'404':
$ref: ../../../../shared/spec/responses/notFound.yml

View File

@ -1,32 +0,0 @@
get:
x-eov-operation-id: getWorkflowVersion
x-required-scope: workflow:read
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Retrieves a specific version of a workflow
description: >-
Deprecated: use GET /workflows/{workflowId}/versions/{workflowVersionId}
instead.
Retrieves a specific version of a workflow from workflow history.
deprecated: true
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
- name: versionId
in: path
required: true
description: The version ID to retrieve
schema:
type: string
example: 'abc123-def456-ghi789'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/workflowVersion.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View File

@ -1,60 +0,0 @@
type: object
additionalProperties: false
required:
- versionId
- workflowId
- nodes
- connections
- authors
properties:
versionId:
type: string
readOnly: true
description: The version ID of this workflow snapshot
example: abc123-def456
workflowId:
type: string
readOnly: true
description: The workflow ID this version belongs to
example: 2tUt1wbLX592XDdX
nodes:
type: array
items:
$ref: './node.yml'
description: Nodes as they were in this version
readOnly: true
connections:
type: object
example: { Jira: { main: [[{ node: 'Jira', type: 'main', index: 0 }]] } }
description: Connections as they were in this version
readOnly: true
nodeGroups:
type: array
readOnly: true
description: Node groups as they were in this version
items:
$ref: './workflowNodeGroup.yml'
authors:
type: string
readOnly: true
description: Authors who created this version
example: John Doe
name:
type: string
nullable: true
description: Workflow name at this version
example: Workflow 1
description:
type: string
nullable: true
description: Workflow description at this version
createdAt:
type: string
format: date-time
readOnly: true
description: When this version was created
updatedAt:
type: string
format: date-time
readOnly: true
description: When this version was last updated

View File

@ -1,52 +0,0 @@
import { EventService } from '@n8n/backend-services';
import { Container } from '@n8n/di';
import { NotFoundError } from '@n8n/errors';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import type { WorkflowRequest } from '../../../types';
import type { PublicAPIEndpoint } from '../../shared/handler.types';
import {
publicApiScope,
projectScope,
deprecated,
} from '../../shared/middlewares/global.middleware';
const VERSION_PATH_DEPRECATED_SINCE = new Date('2026-08-26T00:00:00Z');
type WorkflowHandlers = {
getWorkflowVersion: PublicAPIEndpoint<WorkflowRequest.GetVersion>;
};
const workflowHandlers: WorkflowHandlers = {
getWorkflowVersion: [
deprecated({ since: VERSION_PATH_DEPRECATED_SINCE }),
publicApiScope('workflow:read'),
projectScope('workflow:read', 'workflow'),
async (req, res) => {
const { id: workflowId, versionId } = req.params;
try {
const version = await Container.get(WorkflowHistoryService).getVersion(
req.user,
workflowId,
versionId,
{ includePublishHistory: false },
);
Container.get(EventService).emit('user-retrieved-workflow-version', {
userId: req.user.id,
publicApi: true,
});
const { autosaved, ...versionWithoutInternalFields } = version;
return res.json(versionWithoutInternalFields);
} catch {
throw new NotFoundError('Version not found');
}
},
],
};
export = workflowHandlers;

View File

@ -417,3 +417,6 @@ paths:
$ref: ./handlers/workflows/spec/paths/getWorkflowTags.generated.yml
put:
$ref: ./handlers/workflows/spec/paths/updateWorkflowTags.generated.yml
/workflows/{workflowId}/{workflowVersionId}:
get:
$ref: ./handlers/workflows/spec/paths/getDeprecatedWorkflowVersion.generated.yml

View File

@ -79,8 +79,6 @@ tags:
description: Operations about workflows
paths:
/workflows/{id}/{versionId}:
$ref: './handlers/workflows/spec/paths/workflows.id.versionId.yml'
/n8n-packages/export:
$ref: './handlers/n8n-packages/spec/paths/n8n-packages.export.yml'
/n8n-packages/import:

View File

@ -0,0 +1,229 @@
import { EventService } from '@n8n/backend-services';
import { createWorkflow, mockInstance, testDb } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import { v4 as uuid } from 'uuid';
import { Telemetry } from '@/telemetry';
import { createTestRun } from '../shared/db/evaluation';
import { createTag } from '../shared/db/tags';
import { createOwnerWithApiKey } from '../shared/db/users';
import { createWorkflowHistoryItem } from '../shared/db/workflow-history';
import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils/';
/**
* `GET /workflows/:workflowId/:workflowVersionId` matches every two-segment GET path under
* `/workflows`. Express answers with the first matching route, so the order of registration decides
* which handler gets a request. These tests call the real app with seeded data and check the body
* of each response, so a request that reaches the wrong handler fails the assertion.
*
* Once `GET /workflows/:workflowId/:workflowVersionId` is removed, these tests can be deleted.
*/
mockInstance(Telemetry);
let owner: User;
let authOwnerAgent: SuperAgentTest;
const testServer = utils.setupTestServer({ endpointGroups: ['publicApi'] });
beforeAll(async () => {
owner = await createOwnerWithApiKey();
authOwnerAgent = testServer.publicApiAgentFor(owner);
});
beforeEach(async () => {
await testDb.truncate([
'WorkflowEntity',
'SharedWorkflow',
'WorkflowHistory',
'TagEntity',
'TestRun',
'TestCaseExecution',
]);
});
afterEach(() => {
vi.restoreAllMocks();
});
/** A workflow with three saved versions, two tags and two test runs. */
async function seedWorkflow(label: string, tagNames: string[]) {
const workflow = await createWorkflow({}, owner);
const versions = [];
for (const [index, name] of [`${label} v1`, `${label} v2`, `${label} v3`].entries()) {
const workflowVersion = await createWorkflowHistoryItem(workflow.id, {
versionId: uuid(),
name,
description: `${name} description`,
authors: 'Test Author',
createdAt: new Date(Date.now() + index),
updatedAt: new Date(Date.now() + index),
});
versions.push(workflowVersion);
}
const tags = [];
for (const name of tagNames) {
const tag = await createTag({ name }, workflow);
tags.push(tag);
}
const testRuns = [
await createTestRun(workflow.id, { status: 'completed' }),
await createTestRun(workflow.id, { status: 'error' }),
];
return { workflow, versions, tags, testRuns };
}
async function get(path: string) {
const emit = vi.spyOn(Container.get(EventService), 'emit');
const response = await authOwnerAgent.get(path);
expect(emit.mock.calls.filter(([event]) => event === 'public-api-invoked')).toHaveLength(1);
emit.mockRestore();
return response;
}
describe('workflow routes next to the deprecated version alias', () => {
let target: Awaited<ReturnType<typeof seedWorkflow>>;
beforeEach(async () => {
target = await seedWorkflow('Target', ['production', 'team-a']);
});
test('GET /workflows/:workflowId/history returns the version list of that workflow', async () => {
const response = await get(`/workflows/${target.workflow.id}/history`);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual({
// Newest first version first.
data: [...target.versions].reverse().map((version) => ({
versionId: version.versionId,
workflowId: target.workflow.id,
authors: 'Test Author',
name: version.name,
description: version.description,
createdAt: version.createdAt.toISOString(),
updatedAt: version.updatedAt.toISOString(),
})),
nextCursor: null,
});
});
test('GET /workflows/:workflowId/tags returns the tags of that workflow', async () => {
const response = await get(`/workflows/${target.workflow.id}/tags`);
expect(response.statusCode).toBe(200);
const [production, teamA] = target.tags;
// The tags share one creation time, so the order is not defined. Sort by name.
expect(
response.body.sort((a: { name: string }, b: { name: string }) =>
a.name.localeCompare(b.name),
),
).toStrictEqual([
{
id: production.id,
name: 'production',
createdAt: production.createdAt.toISOString(),
updatedAt: production.updatedAt.toISOString(),
},
{
id: teamA.id,
name: 'team-a',
createdAt: teamA.createdAt.toISOString(),
updatedAt: teamA.updatedAt.toISOString(),
},
]);
});
test('GET /workflows/:workflowId/test-runs returns the test runs of that workflow', async () => {
const response = await get(`/workflows/${target.workflow.id}/test-runs`);
expect(response.statusCode).toBe(200);
const [completedRun, errorRun] = target.testRuns;
// The runs share one creation time, so the order is not defined. Sort by status.
response.body.data.sort((a: { status: string }, b: { status: string }) =>
a.status.localeCompare(b.status),
);
expect(response.body).toStrictEqual({
data: [
{
id: completedRun.id,
status: 'completed',
runAt: null,
completedAt: null,
metrics: {},
errorCode: null,
errorDetails: null,
finalResult: 'success',
testCaseCount: 0,
createdAt: completedRun.createdAt.toISOString(),
updatedAt: completedRun.updatedAt.toISOString(),
},
{
id: errorRun.id,
status: 'error',
runAt: null,
completedAt: null,
metrics: {},
errorCode: null,
errorDetails: null,
finalResult: null,
testCaseCount: 0,
createdAt: errorRun.createdAt.toISOString(),
updatedAt: errorRun.updatedAt.toISOString(),
},
],
nextCursor: null,
});
});
test('GET /workflows/:workflowId/versions/:workflowVersionId returns the full version', async () => {
const [version] = target.versions;
const response = await get(`/workflows/${target.workflow.id}/versions/${version.versionId}`);
expect(response.statusCode).toBe(200);
expect(response.body).toStrictEqual({
versionId: version.versionId,
workflowId: target.workflow.id,
nodes: version.nodes,
connections: version.connections,
nodeGroups: [],
authors: 'Test Author',
name: 'Target v1',
description: 'Target v1 description',
createdAt: version.createdAt.toISOString(),
updatedAt: version.updatedAt.toISOString(),
});
});
test('GET /workflows/:workflowId/:workflowVersionId returns the same version with a Deprecation header', async () => {
const [, version] = target.versions;
const alias = await get(`/workflows/${target.workflow.id}/${version.versionId}`);
const canonical = await get(`/workflows/${target.workflow.id}/versions/${version.versionId}`);
expect(alias.statusCode).toBe(200);
expect(alias.headers.deprecation).toMatch(/^@\d+$/);
expect(alias.body).toStrictEqual({
versionId: version.versionId,
workflowId: target.workflow.id,
nodes: version.nodes,
connections: version.connections,
nodeGroups: [],
authors: 'Test Author',
name: 'Target v2',
description: 'Target v2 description',
createdAt: version.createdAt.toISOString(),
updatedAt: version.updatedAt.toISOString(),
});
expect(alias.body).toStrictEqual(canonical.body);
});
});

View File

@ -1073,6 +1073,7 @@ describe('GET /workflows/:id/:versionId', () => {
const response = await authOwnerAgent.get('/workflows/non-existing/version-123');
expect(response.statusCode).toBe(404);
expect(response.headers.deprecation).toBe('@1787702400');
// The deprecated path keeps one message for both cases. Callers may match on it.
expect(response.body.message).toBe('Version not found');
});
@ -1110,6 +1111,9 @@ describe('GET /workflows/:id/:versionId', () => {
const response = await authOwnerAgent.get(`/workflows/${workflow.id}/${versionId}`);
expect(response.statusCode).toBe(200);
expect(response.headers.deprecation).toBe('@1787702400');
const body = response.body as Partial<WorkflowHistory>;
expect(body).toEqual({
workflowId: workflow.id,

View File

@ -151,7 +151,7 @@
"status": "covered",
"nodeOperation": "workflow:update"
},
"GET /workflows/{id}/{versionId}": {
"GET /workflows/{workflowId}/{workflowVersionId}": {
"status": "covered",
"nodeOperation": "workflow:getVersion"
},