diff --git a/src/app/api/clusters/[cluster]/search-attributes/route.ts b/src/app/api/clusters/[cluster]/search-attributes/route.ts new file mode 100644 index 000000000..147f54e3d --- /dev/null +++ b/src/app/api/clusters/[cluster]/search-attributes/route.ts @@ -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 + ); +} diff --git a/src/route-handlers/get-search-attributes/__tests__/get-search-attributes.node.ts b/src/route-handlers/get-search-attributes/__tests__/get-search-attributes.node.ts new file mode 100644 index 000000000..475dbd0d9 --- /dev/null +++ b/src/route-handlers/get-search-attributes/__tests__/get-search-attributes.node.ts @@ -0,0 +1,126 @@ +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 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, + }; +} diff --git a/src/route-handlers/get-search-attributes/get-search-attributes.constants.ts b/src/route-handlers/get-search-attributes/get-search-attributes.constants.ts new file mode 100644 index 000000000..9f169f218 --- /dev/null +++ b/src/route-handlers/get-search-attributes/get-search-attributes.constants.ts @@ -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 = new Set([ + 'DomainID', + 'WorkflowID', + 'RunID', + 'WorkflowType', + 'StartTime', + 'ExecutionTime', + 'CloseTime', + 'CloseStatus', + 'HistoryLength', + 'TaskList', + 'IsCron', + 'NumClusters', + 'UpdateTime', +]); diff --git a/src/route-handlers/get-search-attributes/get-search-attributes.ts b/src/route-handlers/get-search-attributes/get-search-attributes.ts new file mode 100644 index 000000000..8127757a4 --- /dev/null +++ b/src/route-handlers/get-search-attributes/get-search-attributes.ts @@ -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 { + 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({ + keys: filteredKeys, + } satisfies GetSearchAttributesResponse); + } catch (e) { + logger.error( + { 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) } + ); + } +} diff --git a/src/route-handlers/get-search-attributes/get-search-attributes.types.ts b/src/route-handlers/get-search-attributes/get-search-attributes.types.ts new file mode 100644 index 000000000..2e9a02056 --- /dev/null +++ b/src/route-handlers/get-search-attributes/get-search-attributes.types.ts @@ -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; +}; diff --git a/src/utils/grpc/grpc-client.ts b/src/utils/grpc/grpc-client.ts index b54e25029..c47f8e252 100644 --- a/src/utils/grpc/grpc-client.ts +++ b/src/utils/grpc/grpc-client.ts @@ -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'; @@ -81,6 +83,9 @@ export type GRPCClusterMethods = { getHistory: ( payload: GetWorkflowExecutionHistoryRequest__Input ) => Promise; + getSearchAttributes: ( + payload: GetSearchAttributesRequest__Input + ) => Promise; listDomains: ( payload: ListDomainsRequest__Input ) => Promise; @@ -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 diff --git a/src/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods.ts b/src/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods.ts index 92a295339..1878a8346 100644 --- a/src/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods.ts +++ b/src/utils/route-handlers-middleware/middlewares/__mocks__/grpc-cluster-methods.ts @@ -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(),