Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add route handlers for cancel and terminate #789

Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { type NextRequest } from 'next/server';

import { cancelWorkflow } from '@/route-handlers/cancel-workflow/cancel-workflow';
import { type RouteParams } from '@/route-handlers/cancel-workflow/cancel-workflow.types';
import { routeHandlerWithMiddlewares } from '@/utils/route-handlers-middleware';
import routeHandlersDefaultMiddlewares from '@/utils/route-handlers-middleware/config/route-handlers-default-middlewares.config';

export async function POST(
request: NextRequest,
options: { params: RouteParams }
) {
return routeHandlerWithMiddlewares(
cancelWorkflow,
request,
options,
routeHandlersDefaultMiddlewares
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { type NextRequest } from 'next/server';

import { terminateWorkflow } from '@/route-handlers/terminate-workflow/terminate-workflow';
import { type RouteParams } from '@/route-handlers/terminate-workflow/terminate-workflow.types';
import { routeHandlerWithMiddlewares } from '@/utils/route-handlers-middleware';
import routeHandlersDefaultMiddlewares from '@/utils/route-handlers-middleware/config/route-handlers-default-middlewares.config';

export async function POST(
request: NextRequest,
options: { params: RouteParams }
) {
return routeHandlerWithMiddlewares(
terminateWorkflow,
request,
options,
routeHandlersDefaultMiddlewares
);
}
116 changes: 116 additions & 0 deletions src/route-handlers/cancel-workflow/__tests__/cancel-workflow.node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { NextRequest } from 'next/server';

import { GRPCError } from '@/utils/grpc/grpc-error';
import { mockGrpcClusterMethods } from '@/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods';

import { cancelWorkflow } from '../cancel-workflow';
import {
type CancelWorkflowResponse,
type Context,
} from '../cancel-workflow.types';

describe(cancelWorkflow.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('calls requestCancelWorkflow and returns valid response', async () => {
const { res, mockRequestCancelWorkflow } = await setup({});

expect(mockRequestCancelWorkflow).toHaveBeenCalledWith({
domain: 'mock-domain',
workflowExecution: {
workflowId: 'mock-wfid',
runId: 'mock-runid',
},
cause: 'Requesting workflow cancellation from cadence-web UI',
});

const responseJson = await res.json();
expect(responseJson).toEqual({});
});

it('calls requestCancelWorkflow with cancellation reason', async () => {
const { mockRequestCancelWorkflow } = await setup({
requestBody: JSON.stringify({
cause: 'This workflow needs to be cancelled for various reasons',
}),
});

expect(mockRequestCancelWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
cause: 'This workflow needs to be cancelled for various reasons',
})
);
});

it('returns an error if something went wrong in the backend', async () => {
const { res, mockRequestCancelWorkflow } = await setup({
error: true,
});

expect(mockRequestCancelWorkflow).toHaveBeenCalled();

expect(res.status).toEqual(500);
const responseJson = await res.json();
expect(responseJson).toEqual(
expect.objectContaining({
message: 'Could not cancel workflow',
})
);
});

it('returns an error if the request body is not in an expected format', async () => {
const { res, mockRequestCancelWorkflow } = await setup({
requestBody: JSON.stringify({
cause: 5,
}),
});

expect(mockRequestCancelWorkflow).not.toHaveBeenCalled();

const responseJson = await res.json();
expect(responseJson).toEqual(
expect.objectContaining({
message: 'Invalid values provided for workflow cancellation',
})
);
});
});

async function setup({
requestBody,
error,
}: {
requestBody?: string;
error?: true;
}) {
const mockRequestCancelWorkflow = jest
.spyOn(mockGrpcClusterMethods, 'requestCancelWorkflow')
.mockImplementationOnce(async () => {
if (error) {
throw new GRPCError('Could not cancel workflow');
}
return {} satisfies CancelWorkflowResponse;
});

const res = await cancelWorkflow(
new NextRequest('http://localhost', {
method: 'POST',
body: requestBody ?? '{}',
}),
{
params: {
domain: 'mock-domain',
cluster: 'mock-cluster',
workflowId: 'mock-wfid',
runId: 'mock-runid',
},
},
{
grpcClusterMethods: mockGrpcClusterMethods,
} as Context
);

return { res, mockRequestCancelWorkflow };
}
61 changes: 61 additions & 0 deletions src/route-handlers/cancel-workflow/cancel-workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { type NextRequest, NextResponse } from 'next/server';

import decodeUrlParams from '@/utils/decode-url-params';
import { getHTTPStatusCode, GRPCError } from '@/utils/grpc/grpc-error';
import logger, { type RouteHandlerErrorPayload } from '@/utils/logger';

import {
type CancelWorkflowResponse,
type Context,
type RequestParams,
} from './cancel-workflow.types';
import cancelWorkflowRequestBodySchema from './schemas/cancel-workflow-request-body-schema';

export async function cancelWorkflow(
request: NextRequest,
requestParams: RequestParams,
ctx: Context
) {
const requestBody = await request.json();
const { data, error } =
cancelWorkflowRequestBodySchema.safeParse(requestBody);

if (error) {
return NextResponse.json(
{
message: 'Invalid values provided for workflow cancellation',
validationErrors: error.errors,
},
{ status: 400 }
);
}

const decodedParams = decodeUrlParams(requestParams.params);

try {
await ctx.grpcClusterMethods.requestCancelWorkflow({
domain: decodedParams.domain,
workflowExecution: {
workflowId: decodedParams.workflowId,
runId: decodedParams.runId,
},
cause: data.cause,
});

return NextResponse.json({} satisfies CancelWorkflowResponse);
} catch (e) {
logger.error<RouteHandlerErrorPayload>(
{ requestParams: decodedParams, cause: e },
'Error cancelling workflow'
);

return NextResponse.json(
{
message:
e instanceof GRPCError ? e.message : 'Error cancelling workflow',
cause: e,
},
{ status: getHTTPStatusCode(e) }
);
}
}
17 changes: 17 additions & 0 deletions src/route-handlers/cancel-workflow/cancel-workflow.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type RequestCancelWorkflowExecutionResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/RequestCancelWorkflowExecutionResponse';
import { type DefaultMiddlewaresContext } from '@/utils/route-handlers-middleware';

export type RouteParams = {
domain: string;
cluster: string;
workflowId: string;
runId: string;
};

export type RequestParams = {
params: RouteParams;
};

export type CancelWorkflowResponse = RequestCancelWorkflowExecutionResponse;

export type Context = DefaultMiddlewaresContext;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from 'zod';

const cancelWorkflowRequestBodySchema = z.object({
cause: z
.string()
.optional()
.default('Requesting workflow cancellation from cadence-web UI'),
});

export default cancelWorkflowRequestBodySchema;
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { NextRequest } from 'next/server';

import { GRPCError } from '@/utils/grpc/grpc-error';
import { mockGrpcClusterMethods } from '@/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods';

import { terminateWorkflow } from '../terminate-workflow';
import {
type TerminateWorkflowResponse,
type Context,
} from '../terminate-workflow.types';

describe(terminateWorkflow.name, () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('calls terminateWorkflow and returns valid response', async () => {
const { res, mockTerminateWorkflow } = await setup({});

expect(mockTerminateWorkflow).toHaveBeenCalledWith({
domain: 'mock-domain',
workflowExecution: {
workflowId: 'mock-wfid',
runId: 'mock-runid',
},
reason: 'Terminating workflow from cadence-web UI',
});

const responseJson = await res.json();
expect(responseJson).toEqual({});
});

it('calls terminateWorkflow with termination reason', async () => {
const { mockTerminateWorkflow } = await setup({
requestBody: JSON.stringify({
reason: 'This workflow needs to be terminated for various reasons',
}),
});

expect(mockTerminateWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
reason: 'This workflow needs to be terminated for various reasons',
})
);
});

it('returns an error if something went wrong in the backend', async () => {
const { res, mockTerminateWorkflow } = await setup({
error: true,
});

expect(mockTerminateWorkflow).toHaveBeenCalled();

expect(res.status).toEqual(500);
const responseJson = await res.json();
expect(responseJson).toEqual(
expect.objectContaining({
message: 'Could not terminate workflow',
})
);
});

it('returns an error if the request body is not in an expected format', async () => {
const { res, mockTerminateWorkflow } = await setup({
requestBody: JSON.stringify({
reason: 5,
}),
});

expect(mockTerminateWorkflow).not.toHaveBeenCalled();

const responseJson = await res.json();
expect(responseJson).toEqual(
expect.objectContaining({
message: 'Invalid values provided for workflow termination',
})
);
});
});

async function setup({
requestBody,
error,
}: {
requestBody?: string;
error?: true;
}) {
const mockTerminateWorkflow = jest
.spyOn(mockGrpcClusterMethods, 'terminateWorkflow')
.mockImplementationOnce(async () => {
if (error) {
throw new GRPCError('Could not terminate workflow');
}
return {} satisfies TerminateWorkflowResponse;
});

const res = await terminateWorkflow(
new NextRequest('http://localhost', {
method: 'POST',
body: requestBody ?? '{}',
}),
{
params: {
domain: 'mock-domain',
cluster: 'mock-cluster',
workflowId: 'mock-wfid',
runId: 'mock-runid',
},
},
{
grpcClusterMethods: mockGrpcClusterMethods,
} as Context
);

return { res, mockTerminateWorkflow };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { z } from 'zod';

const terminateWorkflowRequestBodySchema = z.object({
reason: z
.string()
.optional()
.default('Terminating workflow from cadence-web UI'),
});

export default terminateWorkflowRequestBodySchema;
Loading
Loading