Skip to content

Commit 9b74ff1

Browse files
authored
Restart workflow api (#855)
* restart api * add missing restartWorkflow
1 parent 429b79a commit 9b74ff1

File tree

7 files changed

+232
-0
lines changed

7 files changed

+232
-0
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { type NextRequest } from 'next/server';
2+
3+
import { restartWorkflow } from '@/route-handlers/restart-workflow/restart-workflow';
4+
import { type RouteParams } from '@/route-handlers/restart-workflow/restart-workflow.types';
5+
import { routeHandlerWithMiddlewares } from '@/utils/route-handlers-middleware';
6+
import routeHandlersDefaultMiddlewares from '@/utils/route-handlers-middleware/config/route-handlers-default-middlewares.config';
7+
8+
export async function POST(
9+
request: NextRequest,
10+
options: { params: RouteParams }
11+
) {
12+
return routeHandlerWithMiddlewares(
13+
restartWorkflow,
14+
request,
15+
options,
16+
routeHandlersDefaultMiddlewares
17+
);
18+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { NextRequest } from 'next/server';
2+
3+
import { GRPCError } from '@/utils/grpc/grpc-error';
4+
import { mockGrpcClusterMethods } from '@/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods';
5+
6+
import { restartWorkflow } from '../restart-workflow';
7+
import {
8+
type RestartWorkflowResponse,
9+
type Context,
10+
} from '../restart-workflow.types';
11+
12+
describe(restartWorkflow.name, () => {
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
});
16+
17+
it('calls restartWorkflow and returns valid response', async () => {
18+
const { res, mockRestartWorkflow } = await setup({});
19+
20+
expect(mockRestartWorkflow).toHaveBeenCalledWith({
21+
domain: 'mock-domain',
22+
workflowExecution: {
23+
workflowId: 'mock-wfid',
24+
runId: 'mock-runid',
25+
},
26+
reason: 'Restarting workflow from cadence-web UI',
27+
});
28+
29+
const responseJson = await res.json();
30+
expect(responseJson).toEqual({ runId: 'mock-runid-new' });
31+
});
32+
33+
it('calls restartWorkflow with custom reason', async () => {
34+
const { mockRestartWorkflow } = await setup({
35+
requestBody: JSON.stringify({
36+
reason: 'This workflow needs to be restarted for various reasons',
37+
}),
38+
});
39+
40+
expect(mockRestartWorkflow).toHaveBeenCalledWith(
41+
expect.objectContaining({
42+
reason: 'This workflow needs to be restarted for various reasons',
43+
})
44+
);
45+
});
46+
47+
it('returns an error if something went wrong in the backend', async () => {
48+
const { res, mockRestartWorkflow } = await setup({
49+
error: true,
50+
});
51+
52+
expect(mockRestartWorkflow).toHaveBeenCalled();
53+
54+
expect(res.status).toEqual(500);
55+
const responseJson = await res.json();
56+
expect(responseJson).toEqual(
57+
expect.objectContaining({
58+
message: 'Could not restart workflow',
59+
})
60+
);
61+
});
62+
63+
it('returns an error if the request body is not in an expected format', async () => {
64+
const { res, mockRestartWorkflow } = await setup({
65+
requestBody: JSON.stringify({
66+
reason: 5,
67+
}),
68+
});
69+
70+
expect(mockRestartWorkflow).not.toHaveBeenCalled();
71+
72+
const responseJson = await res.json();
73+
expect(responseJson).toEqual(
74+
expect.objectContaining({
75+
message: 'Invalid values provided for workflow restart',
76+
})
77+
);
78+
});
79+
});
80+
81+
async function setup({
82+
requestBody,
83+
error,
84+
}: {
85+
requestBody?: string;
86+
error?: true;
87+
}) {
88+
const mockRestartWorkflow = jest
89+
.spyOn(mockGrpcClusterMethods, 'restartWorkflow')
90+
.mockImplementationOnce(async () => {
91+
if (error) {
92+
throw new GRPCError('Could not restart workflow');
93+
}
94+
return { runId: 'mock-runid-new' } satisfies RestartWorkflowResponse;
95+
});
96+
97+
const res = await restartWorkflow(
98+
new NextRequest('http://localhost', {
99+
method: 'POST',
100+
body: requestBody ?? '{}',
101+
}),
102+
{
103+
params: {
104+
domain: 'mock-domain',
105+
cluster: 'mock-cluster',
106+
workflowId: 'mock-wfid',
107+
runId: 'mock-runid',
108+
},
109+
},
110+
{
111+
grpcClusterMethods: mockGrpcClusterMethods,
112+
} as Context
113+
);
114+
115+
return { res, mockRestartWorkflow };
116+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { type NextRequest, NextResponse } from 'next/server';
2+
3+
import decodeUrlParams from '@/utils/decode-url-params';
4+
import { getHTTPStatusCode, GRPCError } from '@/utils/grpc/grpc-error';
5+
import logger, { type RouteHandlerErrorPayload } from '@/utils/logger';
6+
7+
import { type Context, type RequestParams } from './restart-workflow.types';
8+
import restartWorkflowRequestBodySchema from './schemas/restart-workflow-request-body-schema';
9+
10+
export async function restartWorkflow(
11+
request: NextRequest,
12+
requestParams: RequestParams,
13+
ctx: Context
14+
) {
15+
const requestBody = await request.json();
16+
const { data, error } =
17+
restartWorkflowRequestBodySchema.safeParse(requestBody);
18+
19+
if (error) {
20+
return NextResponse.json(
21+
{
22+
message: 'Invalid values provided for workflow restart',
23+
validationErrors: error.errors,
24+
},
25+
{ status: 400 }
26+
);
27+
}
28+
29+
const decodedParams = decodeUrlParams(requestParams.params);
30+
31+
try {
32+
const response = await ctx.grpcClusterMethods.restartWorkflow({
33+
domain: decodedParams.domain,
34+
workflowExecution: {
35+
workflowId: decodedParams.workflowId,
36+
runId: decodedParams.runId,
37+
},
38+
reason: data.reason,
39+
// TODO: add user identity
40+
});
41+
42+
return NextResponse.json(response);
43+
} catch (e) {
44+
logger.error<RouteHandlerErrorPayload>(
45+
{ requestParams: decodedParams, error: e },
46+
'Error restarting workflow'
47+
);
48+
49+
return NextResponse.json(
50+
{
51+
message:
52+
e instanceof GRPCError ? e.message : 'Error restarting workflow',
53+
cause: e,
54+
},
55+
{ status: getHTTPStatusCode(e) }
56+
);
57+
}
58+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { type RestartWorkflowExecutionResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/RestartWorkflowExecutionResponse';
2+
import { type DefaultMiddlewaresContext } from '@/utils/route-handlers-middleware';
3+
4+
export type RouteParams = {
5+
domain: string;
6+
cluster: string;
7+
workflowId: string;
8+
runId: string;
9+
};
10+
11+
export type RequestParams = {
12+
params: RouteParams;
13+
};
14+
15+
export type RestartWorkflowResponse = RestartWorkflowExecutionResponse;
16+
17+
export type Context = DefaultMiddlewaresContext;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { z } from 'zod';
2+
3+
const restartWorkflowRequestBodySchema = z.object({
4+
reason: z
5+
.string()
6+
.optional()
7+
.default('Restarting workflow from cadence-web UI'),
8+
});
9+
10+
export default restartWorkflowRequestBodySchema;

src/utils/grpc/grpc-client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { type QueryWorkflowRequest__Input } from '@/__generated__/proto-ts/uber/
2424
import { type QueryWorkflowResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/QueryWorkflowResponse';
2525
import { type RequestCancelWorkflowExecutionRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/RequestCancelWorkflowExecutionRequest';
2626
import { type RequestCancelWorkflowExecutionResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/RequestCancelWorkflowExecutionResponse';
27+
import { type RestartWorkflowExecutionRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/RestartWorkflowExecutionRequest';
28+
import { type RestartWorkflowExecutionResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/RestartWorkflowExecutionResponse';
2729
import { type SignalWorkflowExecutionRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/SignalWorkflowExecutionRequest';
2830
import { type SignalWorkflowExecutionResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/SignalWorkflowExecutionResponse';
2931
import { type TerminateWorkflowExecutionRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/TerminateWorkflowExecutionRequest';
@@ -97,6 +99,9 @@ export type GRPCClusterMethods = {
9799
requestCancelWorkflow: (
98100
payload: RequestCancelWorkflowExecutionRequest__Input
99101
) => Promise<RequestCancelWorkflowExecutionResponse>;
102+
restartWorkflow: (
103+
payload: RestartWorkflowExecutionRequest__Input
104+
) => Promise<RestartWorkflowExecutionResponse>;
100105
};
101106

102107
// cache services instances
@@ -283,6 +288,13 @@ const getClusterServicesMethods = async (
283288
method: 'RequestCancelWorkflowExecution',
284289
metadata: metadata,
285290
}),
291+
restartWorkflow: workflowService.request<
292+
RestartWorkflowExecutionRequest__Input,
293+
RestartWorkflowExecutionResponse
294+
>({
295+
method: 'RestartWorkflowExecution',
296+
metadata: metadata,
297+
}),
286298
};
287299
};
288300

src/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export const mockGrpcClusterMethods: GRPCClusterMethods = {
1818
signalWorkflow: jest.fn(),
1919
terminateWorkflow: jest.fn(),
2020
requestCancelWorkflow: jest.fn(),
21+
restartWorkflow: jest.fn(),
2122
};

0 commit comments

Comments
 (0)