Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
18 changes: 18 additions & 0 deletions src/app/api/clusters/[cluster]/search-attributes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { type NextRequest } from 'next/server';

import getSearchAttributes from '@/route-handlers/get-search-attributes/get-search-attributes';
import { type RouteParams } from '@/route-handlers/get-search-attributes/get-search-attributes.types';
import { routeHandlerWithMiddlewares } from '@/utils/route-handlers-middleware';
import routeHandlersDefaultMiddlewares from '@/utils/route-handlers-middleware/config/route-handlers-default-middlewares.config';

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

import { IndexedValueType } from '@/__generated__/proto-ts/uber/cadence/api/v1/IndexedValueType';
import { mockGrpcClusterMethods } from '@/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods';

import getSearchAttributes from '../get-search-attributes';
import {
type Context,
type RequestParams,
} from '../get-search-attributes.types';

jest.mock('../get-search-attributes.constants', () => ({
SYSTEM_SEARCH_ATTRIBUTES: new Set(['WorkflowType', 'CloseTime', 'IsCron']),
}));

const mockSearchAttributesResponse = {
keys: {
WorkflowType: IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD,
CloseTime: IndexedValueType.INDEXED_VALUE_TYPE_DATETIME,
IsCron: IndexedValueType.INDEXED_VALUE_TYPE_BOOL,
CustomAttribute: IndexedValueType.INDEXED_VALUE_TYPE_STRING,
AnotherCustom: IndexedValueType.INDEXED_VALUE_TYPE_INT,
},
};

const baseUrl =
'http://localhost:3000/api/clusters/test-cluster/search-attributes';

describe('getSearchAttributes', () => {
it('should return response with all attributes by default', async () => {
const { mockGetSearchAttributes, response, data } = await setup({
mockResponse: mockSearchAttributesResponse,
});

expect(response.status).toBe(200);
expect(data).toEqual(mockSearchAttributesResponse);
expect(mockGetSearchAttributes).toHaveBeenCalledWith({});
});

it('should handle RPC errors gracefully', async () => {
const { mockGetSearchAttributes, response, data } = await setup({
mockError: new Error('RPC connection failed'),
});

expect(response.status).toBe(500);
expect(data).toHaveProperty('message');
expect(data.message).toContain('Failed to fetch search attributes');
expect(mockGetSearchAttributes).toHaveBeenCalledWith({});
});

it('should handle empty search attributes response', async () => {
const { response, data } = await setup({
mockResponse: { keys: {} },
});

expect(response.status).toBe(200);
expect(data.keys).toEqual({});
});

it('should return other fields from RPC response along with the keys', async () => {
const mockResponse = {
keys: mockSearchAttributesResponse.keys,
metadata: { version: '1.0' },
};

const { response, data } = await setup({
mockResponse,
});

expect(response.status).toBe(200);
expect(data).toEqual(mockResponse);
});

it('should filter system attributes when category=system', async () => {
const expectedSystemKeys = {
WorkflowType: IndexedValueType.INDEXED_VALUE_TYPE_KEYWORD,
CloseTime: IndexedValueType.INDEXED_VALUE_TYPE_DATETIME,
IsCron: IndexedValueType.INDEXED_VALUE_TYPE_BOOL,
};

const { response, data } = await setup({
mockResponse: mockSearchAttributesResponse,
url: `${baseUrl}?category=system`,
});

expect(response.status).toBe(200);
expect(data.keys).toEqual(expectedSystemKeys);
});

it('should filter custom attributes when category=custom', async () => {
const expectedCustomKeys = {
CustomAttribute: IndexedValueType.INDEXED_VALUE_TYPE_STRING,
AnotherCustom: IndexedValueType.INDEXED_VALUE_TYPE_INT,
};

const { response, data } = await setup({
mockResponse: mockSearchAttributesResponse,
url: `${baseUrl}?category=custom`,
});

expect(response.status).toBe(200);
expect(data.keys).toEqual(expectedCustomKeys);
});
});

async function setup(options?: {
mockResponse?: any;
mockError?: Error;
url?: string;
}) {
const mockGetSearchAttributes = jest
.spyOn(mockGrpcClusterMethods, 'getSearchAttributes')
.mockImplementationOnce(async () => {
if (options?.mockError) {
throw options.mockError;
}
return options?.mockResponse || { keys: {} };
});

const context: Context = {
grpcClusterMethods: mockGrpcClusterMethods,
};

const request = new NextRequest(options?.url || baseUrl);
const requestParams: RequestParams = {
params: { cluster: 'test-cluster' },
};

const response = await getSearchAttributes(request, requestParams, context);
const data = await response.json();

return {
mockGetSearchAttributes,
context,
request,
requestParams,
response,
data,
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* System attributes as defined by Cadence.
* These are built-in attributes that are automatically indexed by the system.
* https://github.com/cadence-workflow/cadence/blob/b9e01ea9b881daff690434419b253d1d36fc486a/common/definition/indexedKeys.go#L92
*/
export const SYSTEM_SEARCH_ATTRIBUTES: Set<string> = new Set([
'DomainID',
'WorkflowID',
'RunID',
'WorkflowType',
'StartTime',
'ExecutionTime',
'CloseTime',
'CloseStatus',
'HistoryLength',
'TaskList',
'IsCron',
'NumClusters',
'UpdateTime',
]);
63 changes: 63 additions & 0 deletions src/route-handlers/get-search-attributes/get-search-attributes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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 { SYSTEM_SEARCH_ATTRIBUTES } from './get-search-attributes.constants';
import {
type GetSearchAttributesResponse,
type Context,
type RequestParams,
type RouteParams,
} from './get-search-attributes.types';

export default async function getSearchAttributes(
request: NextRequest,
requestParams: RequestParams,
ctx: Context
): Promise<Response> {
const decodedParams = decodeUrlParams(requestParams.params) as RouteParams;
const category = request.nextUrl.searchParams.get('category');
try {
const searchAttributesResponse =
await ctx.grpcClusterMethods.getSearchAttributes({});

let filteredKeys = searchAttributesResponse.keys || {};

if (category === 'system') {
filteredKeys = Object.fromEntries(
Object.entries(filteredKeys).filter(([key]) =>
SYSTEM_SEARCH_ATTRIBUTES.has(key)
)
);
} else if (category === 'custom') {
filteredKeys = Object.fromEntries(
Object.entries(filteredKeys).filter(
([key]) => !SYSTEM_SEARCH_ATTRIBUTES.has(key)
)
);
}

return NextResponse.json({
...searchAttributesResponse,
keys: filteredKeys,
} satisfies GetSearchAttributesResponse);
} catch (e) {
logger.error<RouteHandlerErrorPayload>(
{ requestParams: decodedParams, error: e },
'Failed to fetch search attributes from Cadence service'
);

return NextResponse.json(
{
message:
e instanceof GRPCError
? e.message
: 'Failed to fetch search attributes',
cause: e,
},
{ status: getHTTPStatusCode(e) }
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { type GRPCClusterMethods } from '@/utils/grpc/grpc-client';

export { type GetSearchAttributesResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/GetSearchAttributesResponse';

export type SearchAttributesCategory = 'all' | 'system' | 'custom';

export type Context = {
grpcClusterMethods: GRPCClusterMethods;
};

export type RouteParams = {
cluster: string;
};

export type RequestParams = {
params: RouteParams;
};
12 changes: 12 additions & 0 deletions src/utils/grpc/grpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { type DescribeTaskListResponse } from '@/__generated__/proto-ts/uber/cad
import { type DescribeWorkflowExecutionResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/DescribeWorkflowExecutionResponse';
import { type DiagnoseWorkflowExecutionRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/DiagnoseWorkflowExecutionRequest';
import { type DiagnoseWorkflowExecutionResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/DiagnoseWorkflowExecutionResponse';
import { type GetSearchAttributesRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/GetSearchAttributesRequest';
import { type GetSearchAttributesResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/GetSearchAttributesResponse';
import { type GetWorkflowExecutionHistoryRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/GetWorkflowExecutionHistoryRequest';
import { type GetWorkflowExecutionHistoryResponse } from '@/__generated__/proto-ts/uber/cadence/api/v1/GetWorkflowExecutionHistoryResponse';
import { type ListArchivedWorkflowExecutionsRequest__Input } from '@/__generated__/proto-ts/uber/cadence/api/v1/ListArchivedWorkflowExecutionsRequest';
Expand Down Expand Up @@ -81,6 +83,9 @@ export type GRPCClusterMethods = {
getHistory: (
payload: GetWorkflowExecutionHistoryRequest__Input
) => Promise<GetWorkflowExecutionHistoryResponse>;
getSearchAttributes: (
payload: GetSearchAttributesRequest__Input
) => Promise<GetSearchAttributesResponse>;
listDomains: (
payload: ListDomainsRequest__Input
) => Promise<ListDomainsResponse>;
Expand Down Expand Up @@ -247,6 +252,13 @@ const getClusterServicesMethods = async (
method: 'GetWorkflowExecutionHistory',
metadata: metadata,
}),
getSearchAttributes: visibilityService.request<
GetSearchAttributesRequest__Input,
GetSearchAttributesResponse
>({
method: 'GetSearchAttributes',
metadata: metadata,
}),
listDomains: domainService.request<
ListDomainsRequest__Input,
ListDomainsResponse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const mockGrpcClusterMethods: GRPCClusterMethods = {
exportHistory: jest.fn(),
getHistory: jest.fn(),
getDiagnosticsWorkflow: jest.fn(),
getSearchAttributes: jest.fn(),
listDomains: jest.fn(),
listTaskListPartitions: jest.fn(),
listWorkflows: jest.fn(),
Expand Down