From 4bc5c339a3d7806aab2db7ffe640774aa66b9e59 Mon Sep 17 00:00:00 2001 From: Amrit Agarwal Date: Mon, 20 Jul 2026 15:34:20 +0530 Subject: [PATCH 1/5] feat(functions): add Functions service for invoking coded functions [PLT-106037] Adds a modular `@uipath/uipath-typescript/functions` service that discovers and invokes JS/Python coded functions, masking the underlying process + HTTP trigger plumbing. - getAll(options?): folder-scoped, paginated listing over /odata/HttpTriggers - invoke(name, input?, options?): resolves the function by name, resolves the folder key, and calls the function's HTTP trigger; returns typed output - bound invoke() on each getAll item Folder context accepted as folderId / folderKey / folderPath (or SDK-init folder context). Verified end-to-end against a live tenant. Co-Authored-By: Claude Fable 5 --- docs/oauth-scopes.md | 9 + docs/pagination.md | 1 + mkdocs.yml | 1 + package.json | 10 + rollup.config.js | 5 + .../orchestrator/functions.constants.ts | 9 + .../orchestrator/functions.internal-types.ts | 49 +++ src/models/orchestrator/functions.models.ts | 179 +++++++++ src/models/orchestrator/functions.types.ts | 65 ++++ src/models/orchestrator/index.ts | 2 + .../orchestrator/functions/functions.ts | 175 +++++++++ src/services/orchestrator/functions/index.ts | 27 ++ src/utils/constants/endpoints/orchestrator.ts | 18 + tests/.env.integration.example | 6 + tests/integration/config/test-config.ts | 6 + tests/integration/config/unified-setup.ts | 3 + .../functions.integration.test.ts | 120 ++++++ .../models/orchestrator/functions.test.ts | 95 +++++ .../services/orchestrator/functions.test.ts | 354 ++++++++++++++++++ tests/utils/constants/functions.ts | 20 + tests/utils/mocks/functions.ts | 92 +++++ 21 files changed, 1246 insertions(+) create mode 100644 src/models/orchestrator/functions.constants.ts create mode 100644 src/models/orchestrator/functions.internal-types.ts create mode 100644 src/models/orchestrator/functions.models.ts create mode 100644 src/models/orchestrator/functions.types.ts create mode 100644 src/services/orchestrator/functions/functions.ts create mode 100644 src/services/orchestrator/functions/index.ts create mode 100644 tests/integration/shared/orchestrator/functions.integration.test.ts create mode 100644 tests/unit/models/orchestrator/functions.test.ts create mode 100644 tests/unit/services/orchestrator/functions.test.ts create mode 100644 tests/utils/constants/functions.ts create mode 100644 tests/utils/mocks/functions.ts diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 31fa76b73..655b4854e 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -22,6 +22,15 @@ This page lists the specific OAuth scopes required in external app for each SDK | `resume()` | `OR.Jobs` or `OR.Jobs.Write` | | `restart()` | `OR.Jobs` | +## Functions + +Coded functions are invoked through their HTTP endpoint, which requires the `OR.Default` scope (auto-granted to any registered external app, but it must appear explicitly in the app's scope string). + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `OR.Default` | +| `invoke()` | `OR.Default`, `OR.Folders` or `OR.Folders.Read` | + ## Attachments | Method | OAuth Scope | diff --git a/docs/pagination.md b/docs/pagination.md index 3cf99df4b..8e4e6d5ac 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -127,6 +127,7 @@ console.log(`Total count: ${allAssets.totalCount}`); | Entities | `getAllRecords()` | ✅ Yes | | Entities | `queryRecordsById()` | ✅ Yes | | Processes | `getAll()` | ✅ Yes | +| Functions | `getAll()` | ✅ Yes | | ProcessInstances | `getAll()` | ❌ No | | CaseInstances | `getAll()` | ❌ No | | CaseInstances | `getActionTasks()` | ✅ Yes | diff --git a/mkdocs.yml b/mkdocs.yml index c2f2fd69e..9b1750a91 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -198,6 +198,7 @@ nav: - Entities: - api/interfaces/entity/index.md - Choice Sets: api/interfaces/ChoiceSetServiceModel.md + - Functions: api/interfaces/FunctionServiceModel.md - Governance: api/interfaces/GovernanceServiceModel.md - Maestro: - Processes: api/interfaces/MaestroProcessesServiceModel.md diff --git a/package.json b/package.json index 0f49b1c63..6d167443a 100644 --- a/package.json +++ b/package.json @@ -225,6 +225,16 @@ "types": "./dist/notifications/index.d.ts", "default": "./dist/notifications/index.cjs" } + }, + "./functions": { + "import": { + "types": "./dist/functions/index.d.ts", + "default": "./dist/functions/index.mjs" + }, + "require": { + "types": "./dist/functions/index.d.ts", + "default": "./dist/functions/index.cjs" + } } }, "files": [ diff --git a/rollup.config.js b/rollup.config.js index 4904bb9d2..a77bba244 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -233,6 +233,11 @@ const serviceEntries = [ name: 'notifications', input: 'src/services/notification/index.ts', output: 'notifications/index' + }, + { + name: 'functions', + input: 'src/services/orchestrator/functions/index.ts', + output: 'functions/index' } ]; diff --git a/src/models/orchestrator/functions.constants.ts b/src/models/orchestrator/functions.constants.ts new file mode 100644 index 000000000..60de00cad --- /dev/null +++ b/src/models/orchestrator/functions.constants.ts @@ -0,0 +1,9 @@ +/** + * Field mapping for Function responses (API field → SDK field). + * Semantic renames only — case conversion is handled by `pascalToCamelCaseKeys()`. + */ +export const FunctionMap = { + organizationUnitId: 'folderId', + organizationUnitFullyQualifiedName: 'folderName', + releaseKey: 'processKey', +} as const; diff --git a/src/models/orchestrator/functions.internal-types.ts b/src/models/orchestrator/functions.internal-types.ts new file mode 100644 index 000000000..1081ef528 --- /dev/null +++ b/src/models/orchestrator/functions.internal-types.ts @@ -0,0 +1,49 @@ +/** + * Internal types for the Functions service — raw API wire formats before + * transformation. Not exported through the public barrel. + */ + +/** + * Raw HTTP trigger row from `GET /odata/HttpTriggers` after + * `pascalToCamelCaseKeys()`. Only the fields consumed by the service are + * declared; the API returns many more job-runner fields that the SDK drops. + */ +export interface RawFunctionTrigger { + /** Trigger identifier (GUID). */ + id: string; + /** Trigger name — unique within a folder. */ + name: string; + /** URL path segment within the package. */ + slug: string; + /** HTTP verb, e.g. `Post`. */ + method: string; + /** Description from the function definition. */ + description?: string | null; + /** Whether the trigger is enabled. */ + enabled: boolean; + /** Default input arguments as a JSON string. */ + inputArguments?: string | null; + /** Source file path inside the package. */ + entryPointPath?: string | null; + /** Key (GUID) of the release that owns the trigger. */ + releaseKey: string; + /** Numeric ID of the folder the trigger lives in. */ + organizationUnitId: number; + /** Fully qualified folder name (null on list responses). */ + organizationUnitFullyQualifiedName?: string | null; + /** Release (process) that packages the function. */ + release: { + id: number; + name: string; + slug: string; + }; +} + +/** + * Raw folder entity from `GET /odata/Folders({id})` — PascalCase wire format, + * limited to the field the Functions service consumes. + */ +export interface RawFolderResponse { + /** Folder key (GUID) — the `t/{key}` segment of the function invoke URL. */ + Key: string; +} diff --git a/src/models/orchestrator/functions.models.ts b/src/models/orchestrator/functions.models.ts new file mode 100644 index 000000000..74b022139 --- /dev/null +++ b/src/models/orchestrator/functions.models.ts @@ -0,0 +1,179 @@ +import { FunctionGetAllOptions, FunctionInvokeOptions, RawFunctionGetResponse } from './functions.types'; +import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination'; + +/** Combined response type for function data with bound methods. */ +export type FunctionGetResponse = RawFunctionGetResponse & FunctionMethods; + +/** + * Service for invoking UiPath Coded Functions. + * + * Coded functions are lightweight units of TypeScript/JavaScript or Python code + * deployed to UiPath and executed on demand. Each function is packaged as a + * process, exposed through an HTTP endpoint, and uniquely named within its folder. + * This service lets you discover deployed functions and invoke them with typed + * input and output — without dealing with the underlying process and trigger plumbing. + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Functions } from '@uipath/uipath-typescript/functions'; + * + * const functions = new Functions(sdk); + * const result = await functions.invoke('my-function', { amount: 42 }, { folderId: }); + * ``` + */ +export interface FunctionServiceModel { + /** + * Gets all functions in a folder with optional filtering and pagination. + * + * Returns each function's identity (name, slug, HTTP method), state, and + * packaging details, with an {@link FunctionMethods.invoke | invoke} method + * bound to each item. Folder context is required — pass one of `folderId`, + * `folderKey`, or `folderPath` in the options, or initialize the SDK with a + * folder context. + * + * @param options - Query options including folder scoping (`folderId` / `folderKey` / `folderPath`), filtering, and pagination options + * @returns Promise resolving to either an array of functions {@link NonPaginatedResponse}<{@link FunctionGetResponse}> or a {@link PaginatedResponse}<{@link FunctionGetResponse}> when pagination options are used. + * {@link FunctionGetResponse} + * @example + * ```typescript + * // Get all functions in a folder + * const functions = await fns.getAll({ folderId: }); + * + * // By folder path + * const shared = await fns.getAll({ folderPath: 'Shared/Finance' }); + * + * // With filtering + * const enabled = await fns.getAll({ + * folderId: , + * filter: 'enabled eq true', + * orderby: 'name asc', + * }); + * + * // First page with pagination + * const page1 = await fns.getAll({ folderId: , pageSize: 10 }); + * + * // Navigate using cursor + * if (page1.hasNextPage) { + * const page2 = await fns.getAll({ folderId: , cursor: page1.nextCursor }); + * } + * ``` + */ + getAll( + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + + /** + * Invokes a function by name and returns its output. + * + * The call is synchronous from the caller's perspective — the platform runs + * the function and returns its result in the same HTTP response. Functions + * should complete within ~25 seconds; longer executions time out. + * + * Type the input and output by supplying the generics — they should match the + * input/output schema the function declares. Folder context is required — pass + * one of `folderId`, `folderKey`, or `folderPath` in the options, or initialize + * the SDK with a folder context. + * + * @param name - Name of the function to invoke (unique within a folder) + * @param input - Input for the function, sent as the request body (or as query + * parameters for functions declared with the `Get` method). Defaults to an empty object. + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) + * @returns Promise resolving to the function's output + * + * @example + * ```typescript + * // Invoke a function + * const result = await fns.invoke('hello', { name: 'Alice' }, { folderId: }); + * ``` + * + * @example + * ```typescript + * // Typed input and output, folder by path + * interface HelloInput { name: string } + * interface HelloOutput { message: string } + * + * const result = await fns.invoke( + * 'hello', + * { name: 'Alice' }, + * { folderPath: 'Shared/Finance' } + * ); + * console.log(result.message); + * ``` + */ + invoke, TOutput = unknown>( + name: string, + input?: TInput, + options?: FunctionInvokeOptions + ): Promise; +} + +/** + * Methods available on function response objects. + * These are bound to the function data and delegate to the service. + */ +export interface FunctionMethods { + /** + * Invokes this function and returns its output. + * + * @param input - Input for the function, sent as the request body (or as query + * parameters for functions declared with the `Get` method). Defaults to an empty object. + * @returns Promise resolving to the function's output + * + * @example + * ```typescript + * const functions = await fns.getAll({ folderId: }); + * const hello = functions.items.find(f => f.name === 'hello'); + * + * if (hello) { + * const result = await hello.invoke({ name: 'Alice' }); + * } + * ``` + */ + invoke, TOutput = unknown>( + input?: TInput + ): Promise; +} + +/** + * Creates methods for a function response object. + * + * @param functionData - The transformed function data + * @param service - The function service instance + * @returns Object containing function methods + */ +function createFunctionMethods( + functionData: RawFunctionGetResponse, + service: FunctionServiceModel +): FunctionMethods { + return { + async invoke, TOutput = unknown>( + input?: TInput + ): Promise { + if (!functionData.name) throw new Error('Function name is undefined'); + if (!functionData.folderId) throw new Error('Function folderId is undefined'); + return service.invoke(functionData.name, input, { folderId: functionData.folderId }); + }, + }; +} + +/** + * Creates a function response with bound methods. + * + * @param functionData - The transformed function data + * @param service - The function service instance + * @returns A function object with added methods + */ +export function createFunctionWithMethods( + functionData: RawFunctionGetResponse, + service: FunctionServiceModel +): FunctionGetResponse { + const methods = createFunctionMethods(functionData, service); + return Object.assign({}, functionData, methods); +} diff --git a/src/models/orchestrator/functions.types.ts b/src/models/orchestrator/functions.types.ts new file mode 100644 index 000000000..8b0fd107c --- /dev/null +++ b/src/models/orchestrator/functions.types.ts @@ -0,0 +1,65 @@ +import { FolderScopedOptions, RequestOptions } from '../common/types'; +import { PaginationOptions } from '../../utils/pagination'; + +/** + * HTTP verb a coded function accepts, declared via `defineFunction` in the + * function's source. + */ +export enum FunctionHttpMethod { + Get = 'Get', + Post = 'Post', + Put = 'Put', + Patch = 'Patch', + Delete = 'Delete', +} + +/** + * A deployed coded function. + * + * A coded function is packaged as a process and exposed for invocation through + * an HTTP endpoint. Each function is uniquely named within its folder. + */ +export interface RawFunctionGetResponse { + /** Unique identifier (GUID) of the function's HTTP endpoint. */ + id: string; + /** Function name — unique within a folder. */ + name: string; + /** URL path segment of the function within its package. */ + slug: string; + /** HTTP verb the function accepts. */ + method: FunctionHttpMethod; + /** Human-readable description from the function definition. */ + description?: string | null; + /** Whether the function can currently be invoked. */ + enabled: boolean; + /** Default input arguments as a JSON string — parse with `JSON.parse()`. */ + inputArguments?: string | null; + /** Source file path of the function inside its package (e.g. `content/functions/hello.ts`). */ + entryPointPath?: string | null; + /** Key (GUID) of the process that packages this function. */ + processKey: string; + /** Display name of the process that packages this function. */ + processName: string; + /** URL slug of the process that packages this function. */ + processSlug: string; + /** ID of the folder the function lives in. */ + folderId: number; + /** Fully qualified name of the folder the function lives in. */ + folderName?: string | null; +} + +/** + * Options for retrieving functions with folder scoping, filtering, and pagination. + * + * Folder context is required: pass one of `folderId`, `folderKey`, or `folderPath`, + * or initialize the SDK with a folder context. + */ +export type FunctionGetAllOptions = RequestOptions & PaginationOptions & FolderScopedOptions; + +/** + * Options for invoking a function. + * + * Folder context is required: pass one of `folderId`, `folderKey`, or `folderPath`, + * or initialize the SDK with a folder context. + */ +export interface FunctionInvokeOptions extends FolderScopedOptions {} diff --git a/src/models/orchestrator/index.ts b/src/models/orchestrator/index.ts index e655f237e..e8786cd8e 100644 --- a/src/models/orchestrator/index.ts +++ b/src/models/orchestrator/index.ts @@ -9,6 +9,8 @@ export * from './jobs.types'; export * from './jobs.models'; export * from './processes.types'; export * from './processes.models'; +export * from './functions.types'; +export * from './functions.models'; export * from './queues.types'; export * from './queues.models'; export * from './attachments.types'; diff --git a/src/services/orchestrator/functions/functions.ts b/src/services/orchestrator/functions/functions.ts new file mode 100644 index 000000000..914608490 --- /dev/null +++ b/src/services/orchestrator/functions/functions.ts @@ -0,0 +1,175 @@ +import { FolderScopedService } from '../../folder-scoped'; +import { + FunctionGetAllOptions, + FunctionHttpMethod, + FunctionInvokeOptions, + RawFunctionGetResponse, +} from '../../../models/orchestrator/functions.types'; +import { RawFolderResponse, RawFunctionTrigger } from '../../../models/orchestrator/functions.internal-types'; +import { + FunctionServiceModel, + FunctionGetResponse, + createFunctionWithMethods, +} from '../../../models/orchestrator/functions.models'; +import { FunctionMap } from '../../../models/orchestrator/functions.constants'; +import { pascalToCamelCaseKeys, transformOptions } from '../../../utils/transform'; +import { FUNCTION_ENDPOINTS, FOLDER_ENDPOINTS } from '../../../utils/constants/endpoints'; +import { ODATA_PAGINATION, ODATA_OFFSET_PARAMS } from '../../../utils/constants/common'; +import { resolveFolderHeaders } from '../../../utils/folder/folder-headers'; +import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../../utils/pagination'; +import { PaginationHelpers } from '../../../utils/pagination/helpers'; +import { PaginationType } from '../../../utils/pagination/internal-types'; +import { track } from '../../../core/telemetry'; + +/** + * Service for discovering and invoking UiPath Coded Functions + */ +export class FunctionService extends FolderScopedService implements FunctionServiceModel { + /** Folder ID → folder key (GUID); folder keys are immutable, so cache hits stay valid. */ + private readonly folderKeyCache = new Map(); + + @track('Functions.GetAll') + async getAll( + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + > { + const { folderId, folderKey, folderPath, ...queryOptions } = options ?? {}; + + const headers = resolveFolderHeaders({ + folderId, + folderKey, + folderPath, + resourceType: 'Functions.getAll', + fallbackFolderKey: this.config.folderKey, + }); + + // Rewrite renamed SDK field names → API names inside OData strings. + const apiOptions = transformOptions(queryOptions, FunctionMap); + + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => FUNCTION_ENDPOINTS.GET_ALL, + headers, + transformFn: (item: Record) => + createFunctionWithMethods(this.toFunctionResponse(item), this), + pagination: { + paginationType: PaginationType.OFFSET, + itemsField: ODATA_PAGINATION.ITEMS_FIELD, + totalCountField: ODATA_PAGINATION.TOTAL_COUNT_FIELD, + paginationParams: { + pageSizeParam: ODATA_OFFSET_PARAMS.PAGE_SIZE_PARAM, + offsetParam: ODATA_OFFSET_PARAMS.OFFSET_PARAM, + countParam: ODATA_OFFSET_PARAMS.COUNT_PARAM, + }, + }, + }, apiOptions) as any; + } + + @track('Functions.Invoke') + async invoke, TOutput = unknown>( + name: string, + input?: TInput, + options?: FunctionInvokeOptions + ): Promise { + const fn = await this.findByName(name, options); + const folderKey = await this.resolveInvokeFolderKey(fn, options); + return this.invokeFunction(fn, folderKey, input ?? {}); + } + + /** Resolves a function by name within the supplied folder context. */ + private async findByName( + name: string, + options?: FunctionInvokeOptions + ): Promise { + return this.getByNameLookup, RawFunctionGetResponse>( + 'Function', + FUNCTION_ENDPOINTS.GET_ALL, + name, + options ?? {}, + (raw) => this.toFunctionResponse(raw), + FunctionMap, + ); + } + + /** + * Resolves the folder key for the invoke URL. Uses the caller-supplied key + * when present, then the SDK's init-time folder context, and otherwise looks + * the key up from the folder ID returned with the function. + */ + private async resolveInvokeFolderKey( + fn: RawFunctionGetResponse, + options?: FunctionInvokeOptions + ): Promise { + const explicitKey = options?.folderKey?.trim(); + if (explicitKey) return explicitKey; + + const hasExplicitFolder = options?.folderId !== undefined || Boolean(options?.folderPath?.trim()); + if (!hasExplicitFolder && this.config.folderKey) return this.config.folderKey; + + return this.getFolderKey(fn.folderId); + } + + /** Looks up a folder's key (GUID) from its numeric ID, with caching. */ + private async getFolderKey(folderId: number): Promise { + const cached = this.folderKeyCache.get(folderId); + if (cached) return cached; + + const response = await this.get(FOLDER_ENDPOINTS.GET_BY_ID(folderId)); + const key = response.data.Key; + this.folderKeyCache.set(folderId, key); + return key; + } + + /** + * Calls the function's HTTP endpoint with the verb it declares. The platform + * runs the function and returns its output as the response body. + */ + private async invokeFunction( + fn: RawFunctionGetResponse, + folderKey: string, + input: object + ): Promise { + const endpoint = FUNCTION_ENDPOINTS.INVOKE(folderKey, fn.processSlug, fn.slug); + + // Functions declared with `Get` read input from query parameters; all other + // verbs receive it as the JSON request body. + const response = fn.method === FunctionHttpMethod.Get + ? await this.get(endpoint, { params: toQueryParams(input) }) + : await this.request(fn.method.toUpperCase(), endpoint, { body: input }); + + return response.data; + } + + /** Maps a raw HttpTriggers row to the public function shape. */ + private toFunctionResponse(raw: Record): RawFunctionGetResponse { + const trigger = pascalToCamelCaseKeys(raw) as RawFunctionTrigger; + return { + id: trigger.id, + name: trigger.name, + slug: trigger.slug, + method: trigger.method as FunctionHttpMethod, + description: trigger.description, + enabled: trigger.enabled, + inputArguments: trigger.inputArguments, + entryPointPath: trigger.entryPointPath, + processKey: trigger.releaseKey, + processName: trigger.release.name, + processSlug: trigger.release.slug, + folderId: trigger.organizationUnitId, + folderName: trigger.organizationUnitFullyQualifiedName, + }; + } +} + +/** Serializes function input for GET invocations (query-string transport). */ +function toQueryParams(input: object): Record { + const params: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null) continue; + params[key] = typeof value === 'object' ? JSON.stringify(value) : (value as string | number | boolean); + } + return params; +} diff --git a/src/services/orchestrator/functions/index.ts b/src/services/orchestrator/functions/index.ts new file mode 100644 index 000000000..f61c6c4e6 --- /dev/null +++ b/src/services/orchestrator/functions/index.ts @@ -0,0 +1,27 @@ +/** + * Coded Functions Module + * + * Provides access to UiPath Coded Functions — lightweight units of + * TypeScript/JavaScript or Python code deployed to UiPath and executed on + * demand. Discover deployed functions and invoke them with typed input and + * output, without dealing with the underlying process and trigger plumbing. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Functions } from '@uipath/uipath-typescript/functions'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const functions = new Functions(sdk); + * const result = await functions.invoke('hello', { name: 'Alice' }, { folderId: 123 }); + * ``` + * + * @module + */ + +export { FunctionService as Functions, FunctionService } from './functions'; + +export * from '../../../models/orchestrator/functions.types'; +export * from '../../../models/orchestrator/functions.models'; diff --git a/src/utils/constants/endpoints/orchestrator.ts b/src/utils/constants/endpoints/orchestrator.ts index e06570bd9..4e3cb92a2 100644 --- a/src/utils/constants/endpoints/orchestrator.ts +++ b/src/utils/constants/endpoints/orchestrator.ts @@ -90,3 +90,21 @@ export const ORCHESTRATOR_DU_MODULE_ENDPOINTS = { SUBMIT_EXCEPTION_REPORT: `${ORCHESTRATOR_BASE}/doc-understanding/DocumentModule/SubmitExceptionReport`, PROCESS_EXTRACTED_DATA: `${ORCHESTRATOR_BASE}/doc-understanding/DocumentModule/ProcessExtractedData`, } as const; + +/** + * Orchestrator Folder Endpoints + */ +export const FOLDER_ENDPOINTS = { + GET_BY_ID: (folderId: number) => `${ORCHESTRATOR_BASE}/odata/Folders(${folderId})`, +} as const; + +/** + * Coded Functions Endpoints + */ +export const FUNCTION_ENDPOINTS = { + /** Folder-scoped list of function HTTP endpoints. */ + GET_ALL: `${ORCHESTRATOR_BASE}/odata/HttpTriggers`, + /** Invokes a function through its HTTP endpoint; the response body is the function output. */ + INVOKE: (folderKey: string, processSlug: string, functionSlug: string) => + `${ORCHESTRATOR_BASE}/t/${folderKey}/${processSlug}/${functionSlug}`, +} as const; diff --git a/tests/.env.integration.example b/tests/.env.integration.example index dac7784a8..2a43381d7 100644 --- a/tests/.env.integration.example +++ b/tests/.env.integration.example @@ -84,3 +84,9 @@ TASKS_TEST_USER_GROUP_ID= # User ID (from tasks.getUsers()) used by the single-user task assignment tests. # The user must have task permissions in the folder identified by INTEGRATION_TEST_FOLDER_ID. TASKS_TEST_USER_ID= + +# Numeric ID of a folder containing at least one deployed coded function. +FUNCTIONS_TEST_FOLDER_ID= + +# Name of a deployed coded function in FUNCTIONS_TEST_FOLDER_ID (used by invoke tests). +FUNCTIONS_TEST_FUNCTION_NAME= diff --git a/tests/integration/config/test-config.ts b/tests/integration/config/test-config.ts index d53d7a405..7c31d9c96 100644 --- a/tests/integration/config/test-config.ts +++ b/tests/integration/config/test-config.ts @@ -39,6 +39,8 @@ export interface IntegrationConfig { jobsTestFolderId?: string; tasksTestUserGroupId?: string; tasksTestUserId?: string; + functionsTestFolderId?: string; + functionsTestFunctionName?: string; } function isValidUrl(value: string): boolean { @@ -100,6 +102,8 @@ function validateConfig(rawConfig: Record): IntegrationConfig { jobsTestFolderId: typeof rawConfig.jobsTestFolderId === 'string' ? rawConfig.jobsTestFolderId : undefined, tasksTestUserGroupId: typeof rawConfig.tasksTestUserGroupId === 'string' ? rawConfig.tasksTestUserGroupId : undefined, tasksTestUserId: typeof rawConfig.tasksTestUserId === 'string' ? rawConfig.tasksTestUserId : undefined, + functionsTestFolderId: typeof rawConfig.functionsTestFolderId === 'string' ? rawConfig.functionsTestFolderId : undefined, + functionsTestFunctionName: typeof rawConfig.functionsTestFunctionName === 'string' ? rawConfig.functionsTestFunctionName : undefined, }; } @@ -145,6 +149,8 @@ export function loadIntegrationConfig(): IntegrationConfig { jobsTestFolderId: process.env.JOBS_TEST_FOLDER_ID || undefined, tasksTestUserGroupId: process.env.TASKS_TEST_USER_GROUP_ID || undefined, tasksTestUserId: process.env.TASKS_TEST_USER_ID || undefined, + functionsTestFolderId: process.env.FUNCTIONS_TEST_FOLDER_ID || undefined, + functionsTestFunctionName: process.env.FUNCTIONS_TEST_FUNCTION_NAME || undefined, }; cachedConfig = validateConfig(rawConfig); diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index 211486f56..6d1cf50cd 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -22,6 +22,7 @@ import { AgentTraces } from '../../../src/services/observability/traces/agent'; import { Traces } from '../../../src/services/observability/traces'; import { Governance } from '../../../src/services/governance'; import { Notifications, Subscriptions } from '../../../src/services/notification'; +import { Functions } from '../../../src/services/orchestrator/functions'; import { loadIntegrationConfig, IntegrationConfig } from './test-config'; import { UiPath as LegacyUiPath } from '../../../src/uipath'; import { afterAll, beforeAll } from 'vitest'; @@ -66,6 +67,7 @@ export interface TestServices { governance?: Governance; notifications?: Notifications; subscriptions?: Subscriptions; + functions?: Functions; } /** @@ -157,6 +159,7 @@ function createV1Services(config: IntegrationConfig): TestServices { governance: new Governance(sdk), notifications: new Notifications(sdk), subscriptions: new Subscriptions(sdk), + functions: new Functions(sdk), }; } diff --git a/tests/integration/shared/orchestrator/functions.integration.test.ts b/tests/integration/shared/orchestrator/functions.integration.test.ts new file mode 100644 index 000000000..eaee22bcf --- /dev/null +++ b/tests/integration/shared/orchestrator/functions.integration.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import { Functions } from '../../../../src/services/orchestrator/functions'; +import { FunctionGetResponse } from '../../../../src/models/orchestrator/functions.models'; + +// New modular service — v1 init only. +const modes: InitMode[] = ['v1']; + +describe.each(modes)('Functions - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let functions!: Functions; + let folderId!: number; + let functionName!: string; + let seededFunction!: FunctionGetResponse; + + beforeAll(async () => { + const service = getServices().functions; + if (!service) { + throw new Error('Functions service is not registered for this init mode'); + } + functions = service; + + const config = getTestConfig(); + if (!config.functionsTestFolderId) { + throw new Error( + 'FUNCTIONS_TEST_FOLDER_ID is not configured. Set it to the numeric ID of a folder ' + + 'that contains at least one deployed coded function.', + ); + } + folderId = Number(config.functionsTestFolderId); + + if (!config.functionsTestFunctionName) { + throw new Error( + 'FUNCTIONS_TEST_FUNCTION_NAME is not configured. Set it to the name of a deployed ' + + 'coded function in the test folder.', + ); + } + functionName = config.functionsTestFunctionName; + + // Shared lookup: fetch the function once so invoke tests can reuse it. + const all = await functions.getAll({ folderId }); + const match = all.items.find((f) => f.name === functionName); + if (!match) { + throw new Error( + `Function '${functionName}' was not found in folder ${folderId}. ` + + 'Ensure it is deployed and its release is added to the folder.', + ); + } + seededFunction = match; + }); + + describe('getAll', () => { + it('should retrieve functions in the folder', async () => { + const result = await functions.getAll({ folderId }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + expect(result.items.length).toBeGreaterThan(0); + }); + + it('should return functions with the transformed SDK shape (no raw PascalCase fields)', async () => { + const fn = seededFunction; + + // Transformed camelCase / reshaped fields are present + expect(typeof fn.name).toBe('string'); + expect(typeof fn.slug).toBe('string'); + expect(typeof fn.method).toBe('string'); + expect(typeof fn.processName).toBe('string'); + expect(typeof fn.processSlug).toBe('string'); + expect(typeof fn.folderId).toBe('number'); + + // Raw API fields must be absent after transformation + expect((fn as any).OrganizationUnitId).toBeUndefined(); + expect((fn as any).ReleaseKey).toBeUndefined(); + expect((fn as any).Release).toBeUndefined(); + + // Bound method is attached + expect(typeof fn.invoke).toBe('function'); + }); + + it('should support pagination', async () => { + const result = await functions.getAll({ folderId, pageSize: 1 }); + + expect(result.items.length).toBeLessThanOrEqual(1); + expect(result.currentPage).toBe(1); + expect(typeof result.hasNextPage).toBe('boolean'); + }); + + it('should resolve folder context from folderKey', async () => { + const config = getTestConfig(); + if (!config.folderKey) { + throw new Error( + 'INTEGRATION_TEST_FOLDER_KEY is not configured; required to exercise folderKey scoping.', + ); + } + + const result = await functions.getAll({ folderKey: config.folderKey }); + expect(Array.isArray(result.items)).toBe(true); + }); + }); + + describe('invoke', () => { + it('should invoke a function by name and return its output', async () => { + const output = await functions.invoke, unknown>( + functionName, + {}, + { folderId }, + ); + + expect(output).toBeDefined(); + }); + + it('should invoke via the bound method on a function response', async () => { + const output = await seededFunction.invoke({}); + + expect(output).toBeDefined(); + }); + }); +}); diff --git a/tests/unit/models/orchestrator/functions.test.ts b/tests/unit/models/orchestrator/functions.test.ts new file mode 100644 index 000000000..cf592f7dd --- /dev/null +++ b/tests/unit/models/orchestrator/functions.test.ts @@ -0,0 +1,95 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { createFunctionWithMethods, FunctionServiceModel } from '../../../../src/models/orchestrator/functions.models'; +import { createBasicFunction } from '../../../utils/mocks/functions'; +import { FUNCTION_TEST_CONSTANTS } from '../../../utils/constants/functions'; +import { TEST_CONSTANTS } from '../../../utils/constants/common'; + +// ===== TEST SUITE ===== +describe('Function Models Unit Tests', () => { + let mockService: FunctionServiceModel; + + beforeEach(() => { + mockService = { + getAll: vi.fn(), + invoke: vi.fn(), + }; + }); + + describe('createFunctionWithMethods', () => { + it('should merge raw data with bound methods', () => { + const functionData = createBasicFunction(); + + const fn = createFunctionWithMethods(functionData, mockService); + + expect(fn.name).toBe(FUNCTION_TEST_CONSTANTS.NAME); + expect(fn.slug).toBe(FUNCTION_TEST_CONSTANTS.SLUG); + expect(fn.processSlug).toBe(FUNCTION_TEST_CONSTANTS.PROCESS_SLUG); + expect(typeof fn.invoke).toBe('function'); + }); + + describe('invoke', () => { + it('should delegate to service.invoke with the captured name and folderId', async () => { + const functionData = createBasicFunction(); + vi.mocked(mockService.invoke).mockResolvedValue(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + + const fn = createFunctionWithMethods(functionData, mockService); + const result = await fn.invoke(FUNCTION_TEST_CONSTANTS.INVOKE_INPUT); + + expect(mockService.invoke).toHaveBeenCalledWith( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ); + expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + }); + + it('should delegate with undefined input when none is given', async () => { + const functionData = createBasicFunction(); + vi.mocked(mockService.invoke).mockResolvedValue(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + + const fn = createFunctionWithMethods(functionData, mockService); + await fn.invoke(); + + expect(mockService.invoke).toHaveBeenCalledWith( + FUNCTION_TEST_CONSTANTS.NAME, + undefined, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ); + }); + + it('should throw when the function name is missing', async () => { + const functionData = createBasicFunction({ name: undefined as unknown as string }); + + const fn = createFunctionWithMethods(functionData, mockService); + + await expect(fn.invoke(FUNCTION_TEST_CONSTANTS.INVOKE_INPUT)).rejects.toThrow( + 'Function name is undefined' + ); + expect(mockService.invoke).not.toHaveBeenCalled(); + }); + + it('should throw when the folderId is missing', async () => { + const functionData = createBasicFunction({ folderId: undefined as unknown as number }); + + const fn = createFunctionWithMethods(functionData, mockService); + + await expect(fn.invoke(FUNCTION_TEST_CONSTANTS.INVOKE_INPUT)).rejects.toThrow( + 'Function folderId is undefined' + ); + expect(mockService.invoke).not.toHaveBeenCalled(); + }); + + it('should propagate errors from the service', async () => { + const functionData = createBasicFunction(); + vi.mocked(mockService.invoke).mockRejectedValue(new Error(TEST_CONSTANTS.ERROR_MESSAGE)); + + const fn = createFunctionWithMethods(functionData, mockService); + + await expect(fn.invoke(FUNCTION_TEST_CONSTANTS.INVOKE_INPUT)).rejects.toThrow( + TEST_CONSTANTS.ERROR_MESSAGE + ); + }); + }); + }); +}); diff --git a/tests/unit/services/orchestrator/functions.test.ts b/tests/unit/services/orchestrator/functions.test.ts new file mode 100644 index 000000000..5dedf0b2b --- /dev/null +++ b/tests/unit/services/orchestrator/functions.test.ts @@ -0,0 +1,354 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { FunctionService } from '../../../../src/services/orchestrator/functions'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { PaginationHelpers } from '../../../../src/utils/pagination/helpers'; +import { + createMockRawFunctionTrigger, + createMockTransformedFunctionCollection, +} from '../../../utils/mocks/functions'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { createMockError } from '../../../utils/mocks/core'; +import { FunctionGetAllOptions, FunctionHttpMethod } from '../../../../src/models/orchestrator/functions.types'; +import { FunctionGetResponse } from '../../../../src/models/orchestrator/functions.models'; +import { PaginatedResponse } from '../../../../src/utils/pagination'; +import { TEST_CONSTANTS } from '../../../utils/constants/common'; +import { FUNCTION_TEST_CONSTANTS } from '../../../utils/constants/functions'; +import { FUNCTION_ENDPOINTS, FOLDER_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { FOLDER_ID, FOLDER_KEY } from '../../../../src/utils/constants/headers'; +import { ValidationError, NotFoundError } from '../../../../src/core/errors'; + +// ===== MOCKING ===== +vi.mock('../../../../src/core/http/api-client'); + +const mocks = vi.hoisted(() => { + return import('../../../utils/mocks/core'); +}); + +vi.mock('../../../../src/utils/pagination/helpers', async () => (await mocks).mockPaginationHelpers); + +// ===== TEST SUITE ===== +describe('FunctionService Unit Tests', () => { + let functionService: FunctionService; + let mockApiClient: any; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + + vi.mocked(ApiClient).mockImplementation(function () { return mockApiClient; }); + + vi.mocked(PaginationHelpers.getAll).mockReset(); + + functionService = new FunctionService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + it('should return all functions in a folder', async () => { + const mockResponse = createMockTransformedFunctionCollection(); + + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + const result = await functionService.getAll({ folderId: TEST_CONSTANTS.FOLDER_ID }); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ + serviceAccess: expect.any(Object), + getEndpoint: expect.toSatisfy((fn: Function) => fn() === FUNCTION_ENDPOINTS.GET_ALL), + headers: expect.objectContaining({ [FOLDER_ID]: String(TEST_CONSTANTS.FOLDER_ID) }), + transformFn: expect.any(Function), + pagination: expect.any(Object), + }), + expect.not.objectContaining({ folderId: expect.anything() }) + ); + + expect(result).toEqual(mockResponse); + }); + + it('should resolve folder context from folderKey', async () => { + const mockResponse = createMockTransformedFunctionCollection(); + + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + await functionService.getAll({ folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ [FOLDER_KEY]: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }), + }), + expect.any(Object) + ); + }); + + it('should throw ValidationError when no folder context is provided', async () => { + await expect(functionService.getAll()).rejects.toThrow(ValidationError); + expect(PaginationHelpers.getAll).not.toHaveBeenCalled(); + }); + + it('should return paginated functions when pagination options provided', async () => { + const mockResponse = createMockTransformedFunctionCollection(10, { + totalCount: 100, + hasNextPage: true, + nextCursor: TEST_CONSTANTS.NEXT_CURSOR, + previousCursor: null, + currentPage: 1, + totalPages: 10, + }); + + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + const options: FunctionGetAllOptions = { + folderId: TEST_CONSTANTS.FOLDER_ID, + pageSize: TEST_CONSTANTS.PAGE_SIZE, + }; + + const result = await functionService.getAll(options) as PaginatedResponse; + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ pageSize: TEST_CONSTANTS.PAGE_SIZE }) + ); + + expect(result).toEqual(mockResponse); + expect(result.hasNextPage).toBe(true); + expect(result.nextCursor).toBe(TEST_CONSTANTS.NEXT_CURSOR); + }); + + it('should pass filter options through to the pagination helper', async () => { + const mockResponse = createMockTransformedFunctionCollection(); + + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + await functionService.getAll({ + folderId: TEST_CONSTANTS.FOLDER_ID, + filter: 'enabled eq true', + }); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ filter: 'enabled eq true' }) + ); + }); + + it('should transform raw triggers to the function shape and drop internal fields', async () => { + const mockResponse = createMockTransformedFunctionCollection(); + + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + await functionService.getAll({ folderId: TEST_CONSTANTS.FOLDER_ID }); + + const { transformFn } = vi.mocked(PaginationHelpers.getAll).mock.calls[0][0]; + const transformed = transformFn!(createMockRawFunctionTrigger()) as FunctionGetResponse; + + // Renamed and reshaped fields carry the raw values + expect(transformed.folderId).toBe(TEST_CONSTANTS.FOLDER_ID); + expect(transformed.folderName).toBe(FUNCTION_TEST_CONSTANTS.FOLDER_NAME); + expect(transformed.processKey).toBe(FUNCTION_TEST_CONSTANTS.PROCESS_KEY); + expect(transformed.processName).toBe(FUNCTION_TEST_CONSTANTS.PROCESS_NAME); + expect(transformed.processSlug).toBe(FUNCTION_TEST_CONSTANTS.PROCESS_SLUG); + expect(transformed.method).toBe(FunctionHttpMethod.Post); + expect(transformed.slug).toBe(FUNCTION_TEST_CONSTANTS.SLUG); + + // Original PascalCase fields are absent + expect((transformed as any).OrganizationUnitId).toBeUndefined(); + expect((transformed as any).ReleaseKey).toBeUndefined(); + expect((transformed as any).Release).toBeUndefined(); + + // Dropped job-runner internals are absent in any casing + expect((transformed as any).callingMode).toBeUndefined(); + expect((transformed as any).jobPriority).toBeUndefined(); + expect((transformed as any).runAsCaller).toBeUndefined(); + + // Bound method is attached + expect(typeof transformed.invoke).toBe('function'); + }); + + it('should propagate errors from the pagination helper', async () => { + vi.mocked(PaginationHelpers.getAll).mockRejectedValue(createMockError()); + + await expect( + functionService.getAll({ folderId: TEST_CONSTANTS.FOLDER_ID }) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('invoke', () => { + it('should look up the function, resolve the folder key, and post the input', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) + .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + + const result = await functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ); + + // Step 1: name lookup on the HttpTriggers endpoint, folder-scoped + expect(mockApiClient.get).toHaveBeenNthCalledWith( + 1, + FUNCTION_ENDPOINTS.GET_ALL, + expect.objectContaining({ + headers: expect.objectContaining({ [FOLDER_ID]: String(TEST_CONSTANTS.FOLDER_ID) }), + params: expect.objectContaining({ + '$filter': `Name eq '${FUNCTION_TEST_CONSTANTS.NAME}'`, + }), + }) + ); + + // Step 2: folder key resolution + expect(mockApiClient.get).toHaveBeenNthCalledWith( + 2, + FOLDER_ENDPOINTS.GET_BY_ID(TEST_CONSTANTS.FOLDER_ID), + expect.any(Object) + ); + + // Step 3: invoke through the function endpoint + expect(mockApiClient.post).toHaveBeenCalledWith( + FUNCTION_ENDPOINTS.INVOKE( + FUNCTION_TEST_CONSTANTS.FOLDER_KEY, + FUNCTION_TEST_CONSTANTS.PROCESS_SLUG, + FUNCTION_TEST_CONSTANTS.SLUG + ), + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + expect.any(Object) + ); + + expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + }); + + it('should skip the folder key lookup when folderKey is provided', async () => { + mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + + const result = await functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY } + ); + + expect(mockApiClient.get).toHaveBeenCalledTimes(1); + expect(mockApiClient.post).toHaveBeenCalledWith( + FUNCTION_ENDPOINTS.INVOKE( + FUNCTION_TEST_CONSTANTS.FOLDER_KEY, + FUNCTION_TEST_CONSTANTS.PROCESS_SLUG, + FUNCTION_TEST_CONSTANTS.SLUG + ), + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + expect.any(Object) + ); + expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + }); + + it('should fall back to the SDK folder context when no folder options are given', async () => { + const { instance } = createServiceTestDependencies({ folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); + const service = new FunctionService(instance); + + mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + + const result = await service.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT + ); + + // Lookup is scoped by the fallback folder key header; no Folders(id) call is made + expect(mockApiClient.get).toHaveBeenCalledTimes(1); + expect(mockApiClient.get).toHaveBeenCalledWith( + FUNCTION_ENDPOINTS.GET_ALL, + expect.objectContaining({ + headers: expect.objectContaining({ [FOLDER_KEY]: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }), + }) + ); + expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + }); + + it('should send an empty object body when input is omitted', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) + .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + + await functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + undefined, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + expect.any(String), + {}, + expect.any(Object) + ); + }); + + it('should invoke functions declared with the Get method via query parameters', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger({ Method: 'Get' })] }) + .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }) + .mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + + const result = await functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ); + + expect(mockApiClient.post).not.toHaveBeenCalled(); + expect(mockApiClient.get).toHaveBeenNthCalledWith( + 3, + FUNCTION_ENDPOINTS.INVOKE( + FUNCTION_TEST_CONSTANTS.FOLDER_KEY, + FUNCTION_TEST_CONSTANTS.PROCESS_SLUG, + FUNCTION_TEST_CONSTANTS.SLUG + ), + expect.objectContaining({ + params: FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + }) + ); + expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + }); + + it('should throw NotFoundError when the function does not exist in the folder', async () => { + mockApiClient.get.mockResolvedValueOnce({ value: [] }); + + await expect( + functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ) + ).rejects.toThrow(NotFoundError); + + expect(mockApiClient.post).not.toHaveBeenCalled(); + }); + + it('should throw ValidationError when no folder context is available', async () => { + await expect( + functionService.invoke(FUNCTION_TEST_CONSTANTS.NAME, FUNCTION_TEST_CONSTANTS.INVOKE_INPUT) + ).rejects.toThrow(ValidationError); + + expect(mockApiClient.get).not.toHaveBeenCalled(); + }); + + it('should propagate errors from the function invocation', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) + .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); + mockApiClient.post.mockRejectedValueOnce(createMockError()); + + await expect( + functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); +}); diff --git a/tests/utils/constants/functions.ts b/tests/utils/constants/functions.ts new file mode 100644 index 000000000..c04bb904f --- /dev/null +++ b/tests/utils/constants/functions.ts @@ -0,0 +1,20 @@ +/** + * Test constants for Functions service tests + */ +export const FUNCTION_TEST_CONSTANTS = { + ID: 'e758581f-2f78-4d86-a8e9-f4bc3aad52ec', + NAME: 'hello', + SLUG: 'hello', + METHOD: 'Post', + DESCRIPTION: 'Returns a greeting message.', + ENTRY_POINT_PATH: 'content/functions/hello.ts', + INPUT_ARGUMENTS: '{"name":"World"}', + PROCESS_KEY: 'd1519612-2961-488e-af7a-7379cc1c3544', + PROCESS_NAME: 'my-functions', + PROCESS_SLUG: 'my-functions', + FOLDER_KEY: '4dbf78cb-576c-4847-9959-788ab5e6dd9d', + FOLDER_NAME: 'Shared/Solution', + INVOKE_INPUT: { name: 'Alice' }, + INVOKE_OUTPUT: { message: 'Hello, Alice!' }, + ERROR_FUNCTION_NOT_FOUND: 'Function not found', +} as const; diff --git a/tests/utils/mocks/functions.ts b/tests/utils/mocks/functions.ts new file mode 100644 index 000000000..3cf7cf7f1 --- /dev/null +++ b/tests/utils/mocks/functions.ts @@ -0,0 +1,92 @@ +import { RawFunctionGetResponse, FunctionHttpMethod } from '../../../src/models/orchestrator/functions.types'; +import { FUNCTION_TEST_CONSTANTS } from '../constants/functions'; +import { TEST_CONSTANTS } from '../constants/common'; +import { createMockBaseResponse, createMockCollection } from './core'; + +/** + * Creates a raw HttpTriggers row as the API returns it (PascalCase wire format, + * including job-runner fields the SDK drops). + */ +export const createMockRawFunctionTrigger = (overrides: Partial = {}): any => { + return { + Type: 'Http', + OrganizationUnitId: TEST_CONSTANTS.FOLDER_ID, + OrganizationUnitFullyQualifiedName: FUNCTION_TEST_CONSTANTS.FOLDER_NAME, + Enabled: true, + ReleaseKey: FUNCTION_TEST_CONSTANTS.PROCESS_KEY, + Name: FUNCTION_TEST_CONSTANTS.NAME, + Description: FUNCTION_TEST_CONSTANTS.DESCRIPTION, + JobPriority: 45, + RunAsMe: false, + RunAsCaller: true, + InputArguments: FUNCTION_TEST_CONSTANTS.INPUT_ARGUMENTS, + EntryPointPath: FUNCTION_TEST_CONSTANTS.ENTRY_POINT_PATH, + Id: FUNCTION_TEST_CONSTANTS.ID, + CallingMode: 'LongPolling', + Method: FUNCTION_TEST_CONSTANTS.METHOD, + Slug: FUNCTION_TEST_CONSTANTS.SLUG, + CallbackMode: 'Disabled', + Release: { + Id: 972287, + Name: FUNCTION_TEST_CONSTANTS.PROCESS_NAME, + Slug: FUNCTION_TEST_CONSTANTS.PROCESS_SLUG, + }, + MachineRobots: [], + Tags: [], + ...overrides, + }; +}; + +/** + * Creates a transformed function (SDK shape, without bound methods). + */ +export const createBasicFunction = ( + overrides: Partial = {} +): RawFunctionGetResponse => { + return { + id: FUNCTION_TEST_CONSTANTS.ID, + name: FUNCTION_TEST_CONSTANTS.NAME, + slug: FUNCTION_TEST_CONSTANTS.SLUG, + method: FunctionHttpMethod.Post, + description: FUNCTION_TEST_CONSTANTS.DESCRIPTION, + enabled: true, + inputArguments: FUNCTION_TEST_CONSTANTS.INPUT_ARGUMENTS, + entryPointPath: FUNCTION_TEST_CONSTANTS.ENTRY_POINT_PATH, + processKey: FUNCTION_TEST_CONSTANTS.PROCESS_KEY, + processName: FUNCTION_TEST_CONSTANTS.PROCESS_NAME, + processSlug: FUNCTION_TEST_CONSTANTS.PROCESS_SLUG, + folderId: TEST_CONSTANTS.FOLDER_ID, + folderName: FUNCTION_TEST_CONSTANTS.FOLDER_NAME, + ...overrides, + }; +}; + +/** + * Creates a mock transformed function collection response. + */ +export const createMockTransformedFunctionCollection = ( + count: number = 1, + options?: { + totalCount?: number; + hasNextPage?: boolean; + nextCursor?: string; + previousCursor?: string | null; + currentPage?: number; + totalPages?: number; + } +): any => { + const items = createMockCollection(count, (index) => createBasicFunction({ + name: `${FUNCTION_TEST_CONSTANTS.NAME}-${index}`, + slug: `${FUNCTION_TEST_CONSTANTS.SLUG}-${index}`, + })); + + return createMockBaseResponse({ + items, + totalCount: options?.totalCount || count, + ...(options?.hasNextPage !== undefined && { hasNextPage: options.hasNextPage }), + ...(options?.nextCursor && { nextCursor: options.nextCursor }), + ...(options?.previousCursor !== undefined && { previousCursor: options.previousCursor }), + ...(options?.currentPage && { currentPage: options.currentPage }), + ...(options?.totalPages && { totalPages: options.totalPages }), + }); +}; From f0026eec82ede8896de0ee4ad981500fbf22df87 Mon Sep 17 00:00:00 2001 From: Amrit Agarwal Date: Wed, 22 Jul 2026 19:15:03 +0530 Subject: [PATCH 2/5] feat(functions): long-running invoke support and parent-job attribution [PLT-106037] - invoke() now follows the gateway's 303 status long-poll chain for functions exceeding the ~25s response window: explicit redirect handling, fresh auth token per poll leg, bounded by a new maxWaitSeconds option (default 300) with a clear timeout error. Browsers fall back to the engine's automatic redirect handling (manual redirects are not readable there). - New jobKey option on invoke: sent as X-UIPATH-JobKey so the run is recorded with the parent job's key (parentJobKey) and inherits its context and licensing transaction. Applied to the invocation leg only. - ApiClient: RequestSpec.redirect passthrough and a raw response type so the invoke leg can observe the 303 chain. Co-Authored-By: Claude Fable 5 --- src/core/http/api-client.ts | 9 +- src/models/common/request-spec.ts | 5 +- src/models/orchestrator/functions.models.ts | 29 +++-- src/models/orchestrator/functions.types.ts | 17 ++- .../orchestrator/functions/functions.ts | 115 +++++++++++++++-- src/utils/constants/headers.ts | 3 +- .../orchestrator/functions.browser.test.ts | 62 +++++++++ .../services/orchestrator/functions.test.ts | 121 ++++++++++++++++-- tests/utils/constants/functions.ts | 2 + 9 files changed, 331 insertions(+), 32 deletions(-) create mode 100644 tests/unit/services/orchestrator/functions.browser.test.ts diff --git a/src/core/http/api-client.ts b/src/core/http/api-client.ts index 0850f294a..7ada75fea 100644 --- a/src/core/http/api-client.ts +++ b/src/core/http/api-client.ts @@ -103,9 +103,16 @@ export class ApiClient { method, headers, body, - signal: options.signal + signal: options.signal, + redirect: options.redirect }); + // Raw response type: hand the Response back untouched (no ok-check, no body + // parsing) for callers that drive the protocol themselves (e.g. 303 long-poll chains). + if (options.responseType === RESPONSE_TYPES.RAW) { + return response as T; + } + if (!response.ok) { const errorInfo = await errorResponseParser.parse(response); throw ErrorFactory.createFromHttpStatus(response.status, errorInfo); diff --git a/src/models/common/request-spec.ts b/src/models/common/request-spec.ts index 2504c99df..8280e9c16 100644 --- a/src/models/common/request-spec.ts +++ b/src/models/common/request-spec.ts @@ -8,7 +8,7 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | /** * Supported response types for API requests */ -export type ResponseType = 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream'; +export type ResponseType = 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream' | 'raw'; /** * Query parameters type with support for arrays and nested objects @@ -102,6 +102,9 @@ export interface RequestSpec { /** AbortSignal for cancelling the request */ signal?: AbortSignal; + /** Redirect handling passed to fetch; 'manual' surfaces 3xx responses to the caller (requires responseType 'raw') */ + redirect?: RequestRedirect; + /** Pagination metadata for the request */ pagination?: PaginationMetadata; } diff --git a/src/models/orchestrator/functions.models.ts b/src/models/orchestrator/functions.models.ts index 74b022139..2ab845415 100644 --- a/src/models/orchestrator/functions.models.ts +++ b/src/models/orchestrator/functions.models.ts @@ -72,9 +72,12 @@ export interface FunctionServiceModel { /** * Invokes a function by name and returns its output. * - * The call is synchronous from the caller's perspective — the platform runs - * the function and returns its result in the same HTTP response. Functions - * should complete within ~25 seconds; longer executions time out. + * The call is synchronous from the caller's perspective — it resolves with the + * function's output. Long-running functions are awaited by following the + * platform's status polling until the output is available, bounded by + * `maxWaitSeconds` (default 300); the function keeps running on the platform + * if the wait is exceeded. In browsers, results are limited to functions that + * complete within the platform's ~25 second response window. * * Type the input and output by supplying the generics — they should match the * input/output schema the function declares. Folder context is required — pass @@ -84,7 +87,8 @@ export interface FunctionServiceModel { * @param name - Name of the function to invoke (unique within a folder) * @param input - Input for the function, sent as the request body (or as query * parameters for functions declared with the `Get` method). Defaults to an empty object. - * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`), the + * maximum wait time (`maxWaitSeconds`), and parent job attribution (`jobKey`) * @returns Promise resolving to the function's output * * @example @@ -95,16 +99,17 @@ export interface FunctionServiceModel { * * @example * ```typescript - * // Typed input and output, folder by path - * interface HelloInput { name: string } - * interface HelloOutput { message: string } + * // Typed input and output; a long-running function with a raised wait limit, + * // attributed to a parent job's licensing transaction + * interface SyncInput { since: string } + * interface SyncOutput { processed: number } * - * const result = await fns.invoke( - * 'hello', - * { name: 'Alice' }, - * { folderPath: 'Shared/Finance' } + * const result = await fns.invoke( + * 'sync-invoices', + * { since: '2026-01-01' }, + * { folderPath: 'Shared/Finance', maxWaitSeconds: 600, jobKey: '' } * ); - * console.log(result.message); + * console.log(result.processed); * ``` */ invoke, TOutput = unknown>( diff --git a/src/models/orchestrator/functions.types.ts b/src/models/orchestrator/functions.types.ts index 8b0fd107c..87ae7c4e2 100644 --- a/src/models/orchestrator/functions.types.ts +++ b/src/models/orchestrator/functions.types.ts @@ -62,4 +62,19 @@ export type FunctionGetAllOptions = RequestOptions & PaginationOptions & FolderS * Folder context is required: pass one of `folderId`, `folderKey`, or `folderPath`, * or initialize the SDK with a folder context. */ -export interface FunctionInvokeOptions extends FolderScopedOptions {} +export interface FunctionInvokeOptions extends FolderScopedOptions { + /** + * Maximum total time, in seconds, to wait for the function to complete. + * Long-running functions are awaited by following the platform's status + * long-poll chain until the output is available. Defaults to 300. + */ + maxWaitSeconds?: number; + /** + * Key (GUID) of the parent job to attribute this invocation to. Sent as the + * `X-UIPATH-JobKey` header; the platform records it as the created job's + * `parentJobKey` so the run inherits the parent job's context and licensing + * transaction. Must be a job key in GUID format — other values are ignored + * by the platform. + */ + jobKey?: string; +} diff --git a/src/services/orchestrator/functions/functions.ts b/src/services/orchestrator/functions/functions.ts index 914608490..fd9882372 100644 --- a/src/services/orchestrator/functions/functions.ts +++ b/src/services/orchestrator/functions/functions.ts @@ -16,11 +16,24 @@ import { pascalToCamelCaseKeys, transformOptions } from '../../../utils/transfor import { FUNCTION_ENDPOINTS, FOLDER_ENDPOINTS } from '../../../utils/constants/endpoints'; import { ODATA_PAGINATION, ODATA_OFFSET_PARAMS } from '../../../utils/constants/common'; import { resolveFolderHeaders } from '../../../utils/folder/folder-headers'; +import { JOB_KEY, RESPONSE_TYPES } from '../../../utils/constants/headers'; +import { createHeaders } from '../../../utils/http/headers'; +import { ServerError } from '../../../core/errors/server'; +import { ErrorFactory } from '../../../core/errors/error-factory'; +import { errorResponseParser } from '../../../core/errors/parser'; import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../../utils/pagination'; import { PaginationHelpers } from '../../../utils/pagination/helpers'; import { PaginationType } from '../../../utils/pagination/internal-types'; +import { isBrowser } from '../../../utils/platform'; import { track } from '../../../core/telemetry'; +/** Default overall wait for a function invocation, in seconds. */ +const DEFAULT_MAX_WAIT_SECONDS = 300; +/** Safety cap on status long-poll legs (each leg holds ~25s at the gateway). */ +const MAX_POLL_LEGS = 60; +/** HTTP 303 — the gateway's "still running, poll this status URL" signal. */ +const HTTP_SEE_OTHER = 303; + /** * Service for discovering and invoking UiPath Coded Functions */ @@ -74,9 +87,18 @@ export class FunctionService extends FolderScopedService implements FunctionServ input?: TInput, options?: FunctionInvokeOptions ): Promise { - const fn = await this.findByName(name, options); - const folderKey = await this.resolveInvokeFolderKey(fn, options); - return this.invokeFunction(fn, folderKey, input ?? {}); + // maxWaitSeconds and jobKey govern only the invocation leg — keep them out + // of the folder-scoped discovery lookup, which would forward them as query params. + const { maxWaitSeconds, jobKey, ...folderOptions } = options ?? {}; + const fn = await this.findByName(name, folderOptions); + const folderKey = await this.resolveInvokeFolderKey(fn, folderOptions); + return this.invokeFunction( + fn, + folderKey, + input ?? {}, + maxWaitSeconds ?? DEFAULT_MAX_WAIT_SECONDS, + jobKey + ); } /** Resolves a function by name within the supplied folder context. */ @@ -126,21 +148,98 @@ export class FunctionService extends FolderScopedService implements FunctionServ /** * Calls the function's HTTP endpoint with the verb it declares. The platform * runs the function and returns its output as the response body. + * + * The gateway holds the request open for ~25s. If the function finishes in + * that window the output comes back directly; otherwise the gateway answers + * 303 with a status URL that itself long-polls — done → 200 + output, still + * running → another 303 with a fresh status URL. We follow that chain + * explicitly (redirects disabled) so the wait is bounded by `maxWaitSeconds`. + * + * Browsers cannot read the 303's Location (`redirect: 'manual'` yields an + * opaqueredirect), so in a browser the request relies on the engine's + * automatic redirect handling instead; `maxWaitSeconds` has no effect there. */ private async invokeFunction( fn: RawFunctionGetResponse, folderKey: string, - input: object + input: object, + maxWaitSeconds: number, + jobKey?: string ): Promise { const endpoint = FUNCTION_ENDPOINTS.INVOKE(folderKey, fn.processSlug, fn.slug); + // Attributes the run to the parent job's licensing transaction when set. + const headers = createHeaders({ [JOB_KEY]: jobKey }); + + if (isBrowser) { + const response = fn.method === FunctionHttpMethod.Get + ? await this.get(endpoint, { params: toQueryParams(input), headers }) + : await this.request(fn.method.toUpperCase(), endpoint, { body: input, headers }); + return response.data; + } + + const deadline = Date.now() + maxWaitSeconds * 1000; + const rawSpec = { redirect: 'manual' as const, responseType: RESPONSE_TYPES.RAW, headers }; // Functions declared with `Get` read input from query parameters; all other // verbs receive it as the JSON request body. - const response = fn.method === FunctionHttpMethod.Get - ? await this.get(endpoint, { params: toQueryParams(input) }) - : await this.request(fn.method.toUpperCase(), endpoint, { body: input }); + let response = fn.method === FunctionHttpMethod.Get + ? (await this.get(endpoint, { ...rawSpec, params: toQueryParams(input) })).data + : (await this.request(fn.method.toUpperCase(), endpoint, { ...rawSpec, body: input })).data; + + let legs = 0; + while (response.status === HTTP_SEE_OTHER) { + const location = response.headers.get('location'); + if (!location) { + throw new ServerError({ + message: `Function '${fn.name}' returned a redirect without a status URL`, + statusCode: response.status, + }); + } + if (++legs > MAX_POLL_LEGS || Date.now() >= deadline) { + throw new ServerError({ + message: `Function '${fn.name}' did not complete within ${maxWaitSeconds}s (maxWaitSeconds). It may still be running on the platform.`, + statusCode: 504, + }); + } + // Location is absolute in practice; resolve against the response URL defensively. + response = await this.pollFunctionStatus(new URL(location, response.url || undefined).toString()); + } + + return this.parseInvokeResponse(response); + } + + /** + * Long-polls one leg of the function status URL. The URL is absolute (it + * points at the portal host, outside the SDK's path routing), so it is + * fetched directly; the bearer token is required — the URL's signature + * alone is rejected with 401. + */ + private async pollFunctionStatus(statusUrl: string): Promise { + const token = await this.getValidAuthToken(); + return fetch(statusUrl, { + headers: { Authorization: `Bearer ${token}` }, + redirect: 'manual', + }); + } - return response.data; + /** Parses the terminal response of an invocation into the function output. */ + private async parseInvokeResponse(response: Response): Promise { + if (!response.ok) { + const errorInfo = await errorResponseParser.parse(response); + throw ErrorFactory.createFromHttpStatus(response.status, errorInfo); + } + const text = await response.text(); + if (!text) { + return undefined as TOutput; + } + try { + return JSON.parse(text); + } catch { + throw new ServerError({ + message: `Function output is not valid JSON (${response.status} ${response.url})`, + statusCode: response.status, + }); + } } /** Maps a raw HttpTriggers row to the public function shape. */ diff --git a/src/utils/constants/headers.ts b/src/utils/constants/headers.ts index afc2a43cb..adf1889a4 100644 --- a/src/utils/constants/headers.ts +++ b/src/utils/constants/headers.ts @@ -26,7 +26,8 @@ export const RESPONSE_TYPES = { JSON: 'json', TEXT: 'text', BLOB: 'blob', - ARRAYBUFFER: 'arraybuffer' + ARRAYBUFFER: 'arraybuffer', + RAW: 'raw' } as const; /** diff --git a/tests/unit/services/orchestrator/functions.browser.test.ts b/tests/unit/services/orchestrator/functions.browser.test.ts new file mode 100644 index 000000000..78cfe61fe --- /dev/null +++ b/tests/unit/services/orchestrator/functions.browser.test.ts @@ -0,0 +1,62 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { FunctionService } from '../../../../src/services/orchestrator/functions'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { createMockRawFunctionTrigger } from '../../../utils/mocks/functions'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { TEST_CONSTANTS } from '../../../utils/constants/common'; +import { FUNCTION_TEST_CONSTANTS } from '../../../utils/constants/functions'; +import { FUNCTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; + +// ===== MOCKING ===== +vi.mock('../../../../src/core/http/api-client'); + +// Browser environment: the invoke leg must rely on the engine's automatic +// redirect handling — no manual 303 chain, no raw response handling. +vi.mock('../../../../src/utils/platform', () => ({ isBrowser: true })); + +// ===== TEST SUITE ===== +describe('FunctionService invoke in browser environments', () => { + let functionService: FunctionService; + let mockApiClient: any; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + + vi.mocked(ApiClient).mockImplementation(function () { return mockApiClient; }); + + functionService = new FunctionService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should invoke via automatic redirect handling (no manual redirect, no raw response)', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) + .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + + const result = await functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + FUNCTION_ENDPOINTS.INVOKE( + FUNCTION_TEST_CONSTANTS.FOLDER_KEY, + FUNCTION_TEST_CONSTANTS.PROCESS_SLUG, + FUNCTION_TEST_CONSTANTS.SLUG + ), + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + expect.not.objectContaining({ redirect: 'manual' }) + ); + const spec = mockApiClient.post.mock.calls[0][2]; + expect(spec.responseType).toBeUndefined(); + + expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + }); +}); diff --git a/tests/unit/services/orchestrator/functions.test.ts b/tests/unit/services/orchestrator/functions.test.ts index 5dedf0b2b..28a76e960 100644 --- a/tests/unit/services/orchestrator/functions.test.ts +++ b/tests/unit/services/orchestrator/functions.test.ts @@ -15,8 +15,14 @@ import { PaginatedResponse } from '../../../../src/utils/pagination'; import { TEST_CONSTANTS } from '../../../utils/constants/common'; import { FUNCTION_TEST_CONSTANTS } from '../../../utils/constants/functions'; import { FUNCTION_ENDPOINTS, FOLDER_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; -import { FOLDER_ID, FOLDER_KEY } from '../../../../src/utils/constants/headers'; -import { ValidationError, NotFoundError } from '../../../../src/core/errors'; +import { FOLDER_ID, FOLDER_KEY, JOB_KEY } from '../../../../src/utils/constants/headers'; +import { ValidationError, NotFoundError, ServerError } from '../../../../src/core/errors'; + +// The invoke leg requests a raw Response (redirect: 'manual'); these helpers build +// the terminal 200 and the gateway's 303 "still running" legs. +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); +const redirectResponse = (statusUrl: string) => + new Response(null, { status: 303, headers: { location: statusUrl } }); // ===== MOCKING ===== vi.mock('../../../../src/core/http/api-client'); @@ -45,6 +51,7 @@ describe('FunctionService Unit Tests', () => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); describe('getAll', () => { @@ -181,7 +188,7 @@ describe('FunctionService Unit Tests', () => { mockApiClient.get .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); - mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); const result = await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -224,7 +231,7 @@ describe('FunctionService Unit Tests', () => { it('should skip the folder key lookup when folderKey is provided', async () => { mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); - mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); const result = await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -245,12 +252,62 @@ describe('FunctionService Unit Tests', () => { expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); }); + it('should send the X-UIPATH-JobKey header on the invocation when jobKey is provided', async () => { + mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); + mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + + await functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY, jobKey: FUNCTION_TEST_CONSTANTS.JOB_KEY } + ); + + // The header rides only the invocation — not the name lookup, in any form + expect(mockApiClient.get).toHaveBeenCalledWith( + FUNCTION_ENDPOINTS.GET_ALL, + expect.objectContaining({ + headers: expect.not.objectContaining({ [JOB_KEY]: expect.anything() }), + params: expect.not.objectContaining({ '$jobKey': expect.anything() }), + }) + ); + expect(mockApiClient.post).toHaveBeenCalledWith( + FUNCTION_ENDPOINTS.INVOKE( + FUNCTION_TEST_CONSTANTS.FOLDER_KEY, + FUNCTION_TEST_CONSTANTS.PROCESS_SLUG, + FUNCTION_TEST_CONSTANTS.SLUG + ), + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + expect.objectContaining({ + headers: expect.objectContaining({ [JOB_KEY]: FUNCTION_TEST_CONSTANTS.JOB_KEY }), + }) + ); + }); + + it('should not send the X-UIPATH-JobKey header when jobKey is omitted', async () => { + mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); + mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + + await functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY } + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + expect.any(String), + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + expect.objectContaining({ + headers: expect.not.objectContaining({ [JOB_KEY]: expect.anything() }), + }) + ); + }); + it('should fall back to the SDK folder context when no folder options are given', async () => { const { instance } = createServiceTestDependencies({ folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); const service = new FunctionService(instance); mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); - mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); const result = await service.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -272,7 +329,7 @@ describe('FunctionService Unit Tests', () => { mockApiClient.get .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); - mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -291,12 +348,12 @@ describe('FunctionService Unit Tests', () => { mockApiClient.get .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger({ Method: 'Get' })] }) .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }) - .mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + .mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); const result = await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, - { folderId: TEST_CONSTANTS.FOLDER_ID } + { folderId: TEST_CONSTANTS.FOLDER_ID, jobKey: FUNCTION_TEST_CONSTANTS.JOB_KEY } ); expect(mockApiClient.post).not.toHaveBeenCalled(); @@ -309,6 +366,7 @@ describe('FunctionService Unit Tests', () => { ), expect.objectContaining({ params: FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + headers: expect.objectContaining({ [JOB_KEY]: FUNCTION_TEST_CONSTANTS.JOB_KEY }), }) ); expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); @@ -336,6 +394,53 @@ describe('FunctionService Unit Tests', () => { expect(mockApiClient.get).not.toHaveBeenCalled(); }); + it('should follow the 303 status long-poll chain until the output is ready', async () => { + mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); + // Invoke leg answers 303 (still running); status polls answer 303 then 200 + output. + mockApiClient.post.mockResolvedValueOnce(redirectResponse(FUNCTION_TEST_CONSTANTS.STATUS_URL)); + const mockFetch = vi.fn() + .mockResolvedValueOnce(redirectResponse(FUNCTION_TEST_CONSTANTS.STATUS_URL)) + .mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + vi.stubGlobal('fetch', mockFetch); + + const result = await functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY } + ); + + expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + expect(mockFetch).toHaveBeenCalledTimes(2); + // Status polls carry the bearer token and never auto-follow redirects + expect(mockFetch).toHaveBeenCalledWith( + FUNCTION_TEST_CONSTANTS.STATUS_URL, + expect.objectContaining({ + redirect: 'manual', + headers: expect.objectContaining({ + Authorization: `Bearer ${TEST_CONSTANTS.DEFAULT_ACCESS_TOKEN}`, + }), + }) + ); + }); + + it('should throw ServerError when the function does not finish within maxWaitSeconds', async () => { + mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); + mockApiClient.post.mockResolvedValueOnce(redirectResponse(FUNCTION_TEST_CONSTANTS.STATUS_URL)); + const mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + + await expect( + functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY, maxWaitSeconds: 0 } + ) + ).rejects.toThrow(ServerError); + + // Deadline already passed — no status poll is issued + expect(mockFetch).not.toHaveBeenCalled(); + }); + it('should propagate errors from the function invocation', async () => { mockApiClient.get .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) diff --git a/tests/utils/constants/functions.ts b/tests/utils/constants/functions.ts index c04bb904f..fbed042a3 100644 --- a/tests/utils/constants/functions.ts +++ b/tests/utils/constants/functions.ts @@ -16,5 +16,7 @@ export const FUNCTION_TEST_CONSTANTS = { FOLDER_NAME: 'Shared/Solution', INVOKE_INPUT: { name: 'Alice' }, INVOKE_OUTPUT: { message: 'Hello, Alice!' }, + JOB_KEY: '7f3f4bd6-6f2e-4c5a-9d38-6f3f0a1b2c3d', + STATUS_URL: 'https://alpha.uipath.com/org/tenant/orchestrator_/t/status/status-token?sig=abc', ERROR_FUNCTION_NOT_FOUND: 'Function not found', } as const; From 25527d19ee7eef60f25ef1b4d874ed61d4c54e7f Mon Sep 17 00:00:00 2001 From: Amrit Agarwal Date: Tue, 28 Jul 2026 17:28:21 +0530 Subject: [PATCH 3/5] refactor(functions): single invoke path, package filtering, better lookup errors [PLT-106037] Coded functions over HTTP are request/response only, so the long-running handling is removed: no platform branch, no manual 303 loop, no job polling. invoke() issues one request and returns the output, letting the engine follow any redirect. Drops the maxWaitSeconds option. Also: - filter on processName / processSlug now maps to the Release navigation path, so functions can be narrowed to one package server-side - a name lookup miss lists the folder's function names, since the usual cause is passing a package name - ApiClient no longer sends Content-Type on GET/HEAD, which Orchestrator rejects for GET function invocations Co-Authored-By: Claude --- src/core/http/api-client.ts | 5 +- .../orchestrator/functions.constants.ts | 4 + src/models/orchestrator/functions.models.ts | 27 +-- src/models/orchestrator/functions.types.ts | 6 - .../orchestrator/functions/functions.ts | 184 +++++++----------- .../functions.integration.test.ts | 11 ++ .../orchestrator/functions.browser.test.ts | 10 +- .../services/orchestrator/functions.test.ts | 146 ++++++++------ tests/utils/constants/functions.ts | 3 +- 9 files changed, 207 insertions(+), 189 deletions(-) diff --git a/src/core/http/api-client.ts b/src/core/http/api-client.ts index 7ada75fea..7bc9ba6e9 100644 --- a/src/core/http/api-client.ts +++ b/src/core/http/api-client.ts @@ -62,7 +62,10 @@ export class ApiClient { const isFormData = options.body instanceof FormData; const defaultHeaders = await this.getDefaultHeaders(); - if (isFormData) { + // FormData sets its own boundary header; GET/HEAD carry no body, and some + // gateways (e.g. function HTTP triggers) reject them when a json + // Content-Type is present. Explicit options.headers still take precedence. + if (isFormData || method === 'GET' || method === 'HEAD') { delete defaultHeaders['Content-Type']; } diff --git a/src/models/orchestrator/functions.constants.ts b/src/models/orchestrator/functions.constants.ts index 60de00cad..e3707d4b1 100644 --- a/src/models/orchestrator/functions.constants.ts +++ b/src/models/orchestrator/functions.constants.ts @@ -6,4 +6,8 @@ export const FunctionMap = { organizationUnitId: 'folderId', organizationUnitFullyQualifiedName: 'folderName', releaseKey: 'processKey', + // The package fields are flattened out of the nested `Release` entity, so the + // API names are navigation paths. Keeps `filter: "processName eq '...'"` working. + 'Release/Name': 'processName', + 'Release/Slug': 'processSlug', } as const; diff --git a/src/models/orchestrator/functions.models.ts b/src/models/orchestrator/functions.models.ts index 2ab845415..d1509697b 100644 --- a/src/models/orchestrator/functions.models.ts +++ b/src/models/orchestrator/functions.models.ts @@ -34,6 +34,9 @@ export interface FunctionServiceModel { * `folderKey`, or `folderPath` in the options, or initialize the SDK with a * folder context. * + * One package can expose several functions, so filter on `processName`, + * `processSlug`, or `processKey` to narrow the result to a single package. + * * @param options - Query options including folder scoping (`folderId` / `folderKey` / `folderPath`), filtering, and pagination options * @returns Promise resolving to either an array of functions {@link NonPaginatedResponse}<{@link FunctionGetResponse}> or a {@link PaginatedResponse}<{@link FunctionGetResponse}> when pagination options are used. * {@link FunctionGetResponse} @@ -52,6 +55,12 @@ export interface FunctionServiceModel { * orderby: 'name asc', * }); * + * // Only the functions deployed from one package + * const fromPackage = await fns.getAll({ + * folderId: , + * filter: "processName eq 'my-functions'", + * }); + * * // First page with pagination * const page1 = await fns.getAll({ folderId: , pageSize: 10 }); * @@ -72,12 +81,9 @@ export interface FunctionServiceModel { /** * Invokes a function by name and returns its output. * - * The call is synchronous from the caller's perspective — it resolves with the - * function's output. Long-running functions are awaited by following the - * platform's status polling until the output is available, bounded by - * `maxWaitSeconds` (default 300); the function keeps running on the platform - * if the wait is exceeded. In browsers, results are limited to functions that - * complete within the platform's ~25 second response window. + * The call is synchronous — it resolves with the function's output. Coded + * functions exposed over HTTP are request/response only, so a run that exceeds + * the platform's execution limit fails rather than continuing in the background. * * Type the input and output by supplying the generics — they should match the * input/output schema the function declares. Folder context is required — pass @@ -87,8 +93,8 @@ export interface FunctionServiceModel { * @param name - Name of the function to invoke (unique within a folder) * @param input - Input for the function, sent as the request body (or as query * parameters for functions declared with the `Get` method). Defaults to an empty object. - * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`), the - * maximum wait time (`maxWaitSeconds`), and parent job attribution (`jobKey`) + * @param options - Folder scoping (`folderId` / `folderKey` / `folderPath`) and + * parent job attribution (`jobKey`) * @returns Promise resolving to the function's output * * @example @@ -99,15 +105,14 @@ export interface FunctionServiceModel { * * @example * ```typescript - * // Typed input and output; a long-running function with a raised wait limit, - * // attributed to a parent job's licensing transaction + * // Typed input and output, attributed to a parent job's licensing transaction * interface SyncInput { since: string } * interface SyncOutput { processed: number } * * const result = await fns.invoke( * 'sync-invoices', * { since: '2026-01-01' }, - * { folderPath: 'Shared/Finance', maxWaitSeconds: 600, jobKey: '' } + * { folderPath: 'Shared/Finance', jobKey: '' } * ); * console.log(result.processed); * ``` diff --git a/src/models/orchestrator/functions.types.ts b/src/models/orchestrator/functions.types.ts index 87ae7c4e2..8e1ea654c 100644 --- a/src/models/orchestrator/functions.types.ts +++ b/src/models/orchestrator/functions.types.ts @@ -63,12 +63,6 @@ export type FunctionGetAllOptions = RequestOptions & PaginationOptions & FolderS * or initialize the SDK with a folder context. */ export interface FunctionInvokeOptions extends FolderScopedOptions { - /** - * Maximum total time, in seconds, to wait for the function to complete. - * Long-running functions are awaited by following the platform's status - * long-poll chain until the output is available. Defaults to 300. - */ - maxWaitSeconds?: number; /** * Key (GUID) of the parent job to attribute this invocation to. Sent as the * `X-UIPATH-JobKey` header; the platform records it as the created job's diff --git a/src/services/orchestrator/functions/functions.ts b/src/services/orchestrator/functions/functions.ts index fd9882372..331721d26 100644 --- a/src/services/orchestrator/functions/functions.ts +++ b/src/services/orchestrator/functions/functions.ts @@ -6,6 +6,10 @@ import { RawFunctionGetResponse, } from '../../../models/orchestrator/functions.types'; import { RawFolderResponse, RawFunctionTrigger } from '../../../models/orchestrator/functions.internal-types'; +import { CollectionResponse } from '../../../models/common/types'; +import { UiPathError } from '../../../core/errors/base'; +import { NotFoundError } from '../../../core/errors/not-found'; +import { isNotFoundError } from '../../../core/errors/guards'; import { FunctionServiceModel, FunctionGetResponse, @@ -16,23 +20,15 @@ import { pascalToCamelCaseKeys, transformOptions } from '../../../utils/transfor import { FUNCTION_ENDPOINTS, FOLDER_ENDPOINTS } from '../../../utils/constants/endpoints'; import { ODATA_PAGINATION, ODATA_OFFSET_PARAMS } from '../../../utils/constants/common'; import { resolveFolderHeaders } from '../../../utils/folder/folder-headers'; -import { JOB_KEY, RESPONSE_TYPES } from '../../../utils/constants/headers'; +import { JOB_KEY } from '../../../utils/constants/headers'; import { createHeaders } from '../../../utils/http/headers'; -import { ServerError } from '../../../core/errors/server'; -import { ErrorFactory } from '../../../core/errors/error-factory'; -import { errorResponseParser } from '../../../core/errors/parser'; import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../../utils/pagination'; import { PaginationHelpers } from '../../../utils/pagination/helpers'; import { PaginationType } from '../../../utils/pagination/internal-types'; -import { isBrowser } from '../../../utils/platform'; import { track } from '../../../core/telemetry'; -/** Default overall wait for a function invocation, in seconds. */ -const DEFAULT_MAX_WAIT_SECONDS = 300; -/** Safety cap on status long-poll legs (each leg holds ~25s at the gateway). */ -const MAX_POLL_LEGS = 60; -/** HTTP 303 — the gateway's "still running, poll this status URL" signal. */ -const HTTP_SEE_OTHER = 303; +/** Cap on the function names listed when a name lookup misses. */ +const MAX_SUGGESTED_NAMES = 20; /** * Service for discovering and invoking UiPath Coded Functions @@ -87,33 +83,77 @@ export class FunctionService extends FolderScopedService implements FunctionServ input?: TInput, options?: FunctionInvokeOptions ): Promise { - // maxWaitSeconds and jobKey govern only the invocation leg — keep them out - // of the folder-scoped discovery lookup, which would forward them as query params. - const { maxWaitSeconds, jobKey, ...folderOptions } = options ?? {}; + // jobKey governs only the invocation leg — keep it out of the folder-scoped + // discovery lookup, which would forward it as a query param. + const { jobKey, ...folderOptions } = options ?? {}; const fn = await this.findByName(name, folderOptions); const folderKey = await this.resolveInvokeFolderKey(fn, folderOptions); - return this.invokeFunction( - fn, - folderKey, - input ?? {}, - maxWaitSeconds ?? DEFAULT_MAX_WAIT_SECONDS, - jobKey - ); + return this.invokeFunction(fn, folderKey, input ?? {}, jobKey); } - /** Resolves a function by name within the supplied folder context. */ + /** + * Resolves a function by name within the supplied folder context. + * + * A miss is usually a package name passed where a function name belongs, so + * the not-found error is enriched with the names the folder actually exposes. + */ private async findByName( name: string, options?: FunctionInvokeOptions ): Promise { - return this.getByNameLookup, RawFunctionGetResponse>( - 'Function', - FUNCTION_ENDPOINTS.GET_ALL, - name, - options ?? {}, - (raw) => this.toFunctionResponse(raw), - FunctionMap, - ); + try { + return await this.getByNameLookup, RawFunctionGetResponse>( + 'Function', + FUNCTION_ENDPOINTS.GET_ALL, + name, + options ?? {}, + (raw) => this.toFunctionResponse(raw), + FunctionMap, + ); + } catch (error) { + if (isNotFoundError(error)) { + throw await this.withAvailableFunctionNames(error, options); + } + throw error; + } + } + + /** + * Appends the folder's function names to a not-found error. Returns the + * original error unchanged if the names cannot be listed — a diagnostic must + * never mask the failure it describes. + */ + private async withAvailableFunctionNames( + error: NotFoundError, + options?: FunctionInvokeOptions + ): Promise { + try { + const headers = resolveFolderHeaders({ + folderId: options?.folderId, + folderKey: options?.folderKey, + folderPath: options?.folderPath, + resourceType: 'Function.getByName', + fallbackFolderKey: this.config.folderKey, + }); + + const response = await this.get>(FUNCTION_ENDPOINTS.GET_ALL, { + headers, + params: { $select: 'Name', $top: String(MAX_SUGGESTED_NAMES + 1) }, + }); + + const names = response.data?.value?.map((item) => item.Name).filter(Boolean) ?? []; + if (!names.length) { + return new NotFoundError({ message: `${error.message} The folder exposes no functions.` }); + } + + const shown = names.slice(0, MAX_SUGGESTED_NAMES).join(', '); + const suffix = names.length > MAX_SUGGESTED_NAMES ? `, and ${names.length - MAX_SUGGESTED_NAMES} more` : ''; + return new NotFoundError({ + message: `${error.message} Available functions: ${shown}${suffix}. Note that a function name is not the name of the package it is deployed from.`, + }); + } catch { + return error; + } } /** @@ -147,99 +187,25 @@ export class FunctionService extends FolderScopedService implements FunctionServ /** * Calls the function's HTTP endpoint with the verb it declares. The platform - * runs the function and returns its output as the response body. - * - * The gateway holds the request open for ~25s. If the function finishes in - * that window the output comes back directly; otherwise the gateway answers - * 303 with a status URL that itself long-polls — done → 200 + output, still - * running → another 303 with a fresh status URL. We follow that chain - * explicitly (redirects disabled) so the wait is bounded by `maxWaitSeconds`. - * - * Browsers cannot read the 303's Location (`redirect: 'manual'` yields an - * opaqueredirect), so in a browser the request relies on the engine's - * automatic redirect handling instead; `maxWaitSeconds` has no effect there. + * runs the function and answers with its output as the response body. */ private async invokeFunction( fn: RawFunctionGetResponse, folderKey: string, input: object, - maxWaitSeconds: number, jobKey?: string ): Promise { const endpoint = FUNCTION_ENDPOINTS.INVOKE(folderKey, fn.processSlug, fn.slug); // Attributes the run to the parent job's licensing transaction when set. const headers = createHeaders({ [JOB_KEY]: jobKey }); - if (isBrowser) { - const response = fn.method === FunctionHttpMethod.Get - ? await this.get(endpoint, { params: toQueryParams(input), headers }) - : await this.request(fn.method.toUpperCase(), endpoint, { body: input, headers }); - return response.data; - } - - const deadline = Date.now() + maxWaitSeconds * 1000; - const rawSpec = { redirect: 'manual' as const, responseType: RESPONSE_TYPES.RAW, headers }; - // Functions declared with `Get` read input from query parameters; all other // verbs receive it as the JSON request body. - let response = fn.method === FunctionHttpMethod.Get - ? (await this.get(endpoint, { ...rawSpec, params: toQueryParams(input) })).data - : (await this.request(fn.method.toUpperCase(), endpoint, { ...rawSpec, body: input })).data; - - let legs = 0; - while (response.status === HTTP_SEE_OTHER) { - const location = response.headers.get('location'); - if (!location) { - throw new ServerError({ - message: `Function '${fn.name}' returned a redirect without a status URL`, - statusCode: response.status, - }); - } - if (++legs > MAX_POLL_LEGS || Date.now() >= deadline) { - throw new ServerError({ - message: `Function '${fn.name}' did not complete within ${maxWaitSeconds}s (maxWaitSeconds). It may still be running on the platform.`, - statusCode: 504, - }); - } - // Location is absolute in practice; resolve against the response URL defensively. - response = await this.pollFunctionStatus(new URL(location, response.url || undefined).toString()); - } - - return this.parseInvokeResponse(response); - } + const response = fn.method === FunctionHttpMethod.Get + ? await this.get(endpoint, { params: toQueryParams(input), headers }) + : await this.request(fn.method.toUpperCase(), endpoint, { body: input, headers }); - /** - * Long-polls one leg of the function status URL. The URL is absolute (it - * points at the portal host, outside the SDK's path routing), so it is - * fetched directly; the bearer token is required — the URL's signature - * alone is rejected with 401. - */ - private async pollFunctionStatus(statusUrl: string): Promise { - const token = await this.getValidAuthToken(); - return fetch(statusUrl, { - headers: { Authorization: `Bearer ${token}` }, - redirect: 'manual', - }); - } - - /** Parses the terminal response of an invocation into the function output. */ - private async parseInvokeResponse(response: Response): Promise { - if (!response.ok) { - const errorInfo = await errorResponseParser.parse(response); - throw ErrorFactory.createFromHttpStatus(response.status, errorInfo); - } - const text = await response.text(); - if (!text) { - return undefined as TOutput; - } - try { - return JSON.parse(text); - } catch { - throw new ServerError({ - message: `Function output is not valid JSON (${response.status} ${response.url})`, - statusCode: response.status, - }); - } + return response.data; } /** Maps a raw HttpTriggers row to the public function shape. */ diff --git a/tests/integration/shared/orchestrator/functions.integration.test.ts b/tests/integration/shared/orchestrator/functions.integration.test.ts index eaee22bcf..56f19cbbb 100644 --- a/tests/integration/shared/orchestrator/functions.integration.test.ts +++ b/tests/integration/shared/orchestrator/functions.integration.test.ts @@ -87,6 +87,17 @@ describe.each(modes)('Functions - Integration Tests [%s]', (mode) => { expect(typeof result.hasNextPage).toBe('boolean'); }); + it('should filter to the functions of a single package by name', async () => { + // processName maps to the nested Release/Name navigation path server-side. + const result = await functions.getAll({ + folderId, + filter: `processName eq '${seededFunction.processName}'`, + }); + + expect(result.items.length).toBeGreaterThan(0); + expect(result.items.every((f) => f.processName === seededFunction.processName)).toBe(true); + }); + it('should resolve folder context from folderKey', async () => { const config = getTestConfig(); if (!config.folderKey) { diff --git a/tests/unit/services/orchestrator/functions.browser.test.ts b/tests/unit/services/orchestrator/functions.browser.test.ts index 78cfe61fe..eda79b8e3 100644 --- a/tests/unit/services/orchestrator/functions.browser.test.ts +++ b/tests/unit/services/orchestrator/functions.browser.test.ts @@ -11,8 +11,9 @@ import { FUNCTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; // ===== MOCKING ===== vi.mock('../../../../src/core/http/api-client'); -// Browser environment: the invoke leg must rely on the engine's automatic -// redirect handling — no manual 303 chain, no raw response handling. +// Browser environment. Invocation must behave exactly as it does in Node — the +// service has no platform branch and never inspects response headers, which a +// browser cannot read on a cross-origin call. vi.mock('../../../../src/utils/platform', () => ({ isBrowser: true })); // ===== TEST SUITE ===== @@ -33,7 +34,7 @@ describe('FunctionService invoke in browser environments', () => { vi.clearAllMocks(); }); - it('should invoke via automatic redirect handling (no manual redirect, no raw response)', async () => { + it('should invoke without manual redirect handling', async () => { mockApiClient.get .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); @@ -54,9 +55,6 @@ describe('FunctionService invoke in browser environments', () => { FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, expect.not.objectContaining({ redirect: 'manual' }) ); - const spec = mockApiClient.post.mock.calls[0][2]; - expect(spec.responseType).toBeUndefined(); - expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); }); }); diff --git a/tests/unit/services/orchestrator/functions.test.ts b/tests/unit/services/orchestrator/functions.test.ts index 28a76e960..8e5db54fc 100644 --- a/tests/unit/services/orchestrator/functions.test.ts +++ b/tests/unit/services/orchestrator/functions.test.ts @@ -16,13 +16,7 @@ import { TEST_CONSTANTS } from '../../../utils/constants/common'; import { FUNCTION_TEST_CONSTANTS } from '../../../utils/constants/functions'; import { FUNCTION_ENDPOINTS, FOLDER_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; import { FOLDER_ID, FOLDER_KEY, JOB_KEY } from '../../../../src/utils/constants/headers'; -import { ValidationError, NotFoundError, ServerError } from '../../../../src/core/errors'; - -// The invoke leg requests a raw Response (redirect: 'manual'); these helpers build -// the terminal 200 and the gateway's 303 "still running" legs. -const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); -const redirectResponse = (statusUrl: string) => - new Response(null, { status: 303, headers: { location: statusUrl } }); +import { ValidationError, NotFoundError } from '../../../../src/core/errors'; // ===== MOCKING ===== vi.mock('../../../../src/core/http/api-client'); @@ -125,6 +119,45 @@ describe('FunctionService Unit Tests', () => { expect(result.nextCursor).toBe(TEST_CONSTANTS.NEXT_CURSOR); }); + it('should rewrite package field names to their API navigation paths in filters', async () => { + const mockResponse = createMockTransformedFunctionCollection(); + + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + await functionService.getAll({ + folderId: TEST_CONSTANTS.FOLDER_ID, + filter: `processName eq '${FUNCTION_TEST_CONSTANTS.PROCESS_NAME}'`, + orderby: 'processSlug asc', + }); + + // processName / processSlug are flattened from the nested Release entity + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + filter: `Release/Name eq '${FUNCTION_TEST_CONSTANTS.PROCESS_NAME}'`, + orderby: 'Release/Slug asc', + }) + ); + }); + + it('should rewrite processKey to the API field name in filters', async () => { + const mockResponse = createMockTransformedFunctionCollection(); + + vi.mocked(PaginationHelpers.getAll).mockResolvedValue(mockResponse); + + await functionService.getAll({ + folderId: TEST_CONSTANTS.FOLDER_ID, + filter: `processKey eq '${FUNCTION_TEST_CONSTANTS.PROCESS_KEY}'`, + }); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + filter: `releaseKey eq '${FUNCTION_TEST_CONSTANTS.PROCESS_KEY}'`, + }) + ); + }); + it('should pass filter options through to the pagination helper', async () => { const mockResponse = createMockTransformedFunctionCollection(); @@ -188,7 +221,7 @@ describe('FunctionService Unit Tests', () => { mockApiClient.get .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); - mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); const result = await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -231,7 +264,7 @@ describe('FunctionService Unit Tests', () => { it('should skip the folder key lookup when folderKey is provided', async () => { mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); - mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); const result = await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -254,7 +287,7 @@ describe('FunctionService Unit Tests', () => { it('should send the X-UIPATH-JobKey header on the invocation when jobKey is provided', async () => { mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); - mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -285,7 +318,7 @@ describe('FunctionService Unit Tests', () => { it('should not send the X-UIPATH-JobKey header when jobKey is omitted', async () => { mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); - mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -307,7 +340,7 @@ describe('FunctionService Unit Tests', () => { const service = new FunctionService(instance); mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); - mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); const result = await service.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -329,7 +362,7 @@ describe('FunctionService Unit Tests', () => { mockApiClient.get .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); - mockApiClient.post.mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -348,7 +381,7 @@ describe('FunctionService Unit Tests', () => { mockApiClient.get .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger({ Method: 'Get' })] }) .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }) - .mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); + .mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); const result = await functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, @@ -373,7 +406,10 @@ describe('FunctionService Unit Tests', () => { }); it('should throw NotFoundError when the function does not exist in the folder', async () => { - mockApiClient.get.mockResolvedValueOnce({ value: [] }); + // Name lookup misses, then the folder's function names are listed for the error. + mockApiClient.get + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ value: [] }); await expect( functionService.invoke( @@ -386,59 +422,59 @@ describe('FunctionService Unit Tests', () => { expect(mockApiClient.post).not.toHaveBeenCalled(); }); - it('should throw ValidationError when no folder context is available', async () => { - await expect( - functionService.invoke(FUNCTION_TEST_CONSTANTS.NAME, FUNCTION_TEST_CONSTANTS.INVOKE_INPUT) - ).rejects.toThrow(ValidationError); + it('should list the folder\'s function names when a name lookup misses', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ + value: [{ Name: FUNCTION_TEST_CONSTANTS.NAME }, { Name: FUNCTION_TEST_CONSTANTS.OTHER_NAME }], + }); - expect(mockApiClient.get).not.toHaveBeenCalled(); + // A package name passed where a function name belongs — the common mistake. + await expect( + functionService.invoke( + FUNCTION_TEST_CONSTANTS.PROCESS_NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ) + ).rejects.toThrow( + new RegExp(`Available functions: ${FUNCTION_TEST_CONSTANTS.NAME}, ${FUNCTION_TEST_CONSTANTS.OTHER_NAME}`) + ); }); - it('should follow the 303 status long-poll chain until the output is ready', async () => { - mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); - // Invoke leg answers 303 (still running); status polls answer 303 then 200 + output. - mockApiClient.post.mockResolvedValueOnce(redirectResponse(FUNCTION_TEST_CONSTANTS.STATUS_URL)); - const mockFetch = vi.fn() - .mockResolvedValueOnce(redirectResponse(FUNCTION_TEST_CONSTANTS.STATUS_URL)) - .mockResolvedValueOnce(jsonResponse(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT)); - vi.stubGlobal('fetch', mockFetch); - - const result = await functionService.invoke( - FUNCTION_TEST_CONSTANTS.NAME, - FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, - { folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY } - ); + it('should say so when the folder exposes no functions at all', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ value: [] }); - expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); - expect(mockFetch).toHaveBeenCalledTimes(2); - // Status polls carry the bearer token and never auto-follow redirects - expect(mockFetch).toHaveBeenCalledWith( - FUNCTION_TEST_CONSTANTS.STATUS_URL, - expect.objectContaining({ - redirect: 'manual', - headers: expect.objectContaining({ - Authorization: `Bearer ${TEST_CONSTANTS.DEFAULT_ACCESS_TOKEN}`, - }), - }) - ); + await expect( + functionService.invoke( + FUNCTION_TEST_CONSTANTS.NAME, + FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ) + ).rejects.toThrow(/exposes no functions/); }); - it('should throw ServerError when the function does not finish within maxWaitSeconds', async () => { - mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); - mockApiClient.post.mockResolvedValueOnce(redirectResponse(FUNCTION_TEST_CONSTANTS.STATUS_URL)); - const mockFetch = vi.fn(); - vi.stubGlobal('fetch', mockFetch); + it('should keep the original error when the name listing itself fails', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [] }) + .mockRejectedValueOnce(createMockError()); await expect( functionService.invoke( FUNCTION_TEST_CONSTANTS.NAME, FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, - { folderKey: FUNCTION_TEST_CONSTANTS.FOLDER_KEY, maxWaitSeconds: 0 } + { folderId: TEST_CONSTANTS.FOLDER_ID } ) - ).rejects.toThrow(ServerError); + ).rejects.toThrow(NotFoundError); + }); - // Deadline already passed — no status poll is issued - expect(mockFetch).not.toHaveBeenCalled(); + it('should throw ValidationError when no folder context is available', async () => { + await expect( + functionService.invoke(FUNCTION_TEST_CONSTANTS.NAME, FUNCTION_TEST_CONSTANTS.INVOKE_INPUT) + ).rejects.toThrow(ValidationError); + + expect(mockApiClient.get).not.toHaveBeenCalled(); }); it('should propagate errors from the function invocation', async () => { diff --git a/tests/utils/constants/functions.ts b/tests/utils/constants/functions.ts index fbed042a3..b3bfb5ed8 100644 --- a/tests/utils/constants/functions.ts +++ b/tests/utils/constants/functions.ts @@ -4,6 +4,8 @@ export const FUNCTION_TEST_CONSTANTS = { ID: 'e758581f-2f78-4d86-a8e9-f4bc3aad52ec', NAME: 'hello', + /** A second function in the same package — used for name-listing assertions. */ + OTHER_NAME: 'echo-headers', SLUG: 'hello', METHOD: 'Post', DESCRIPTION: 'Returns a greeting message.', @@ -17,6 +19,5 @@ export const FUNCTION_TEST_CONSTANTS = { INVOKE_INPUT: { name: 'Alice' }, INVOKE_OUTPUT: { message: 'Hello, Alice!' }, JOB_KEY: '7f3f4bd6-6f2e-4c5a-9d38-6f3f0a1b2c3d', - STATUS_URL: 'https://alpha.uipath.com/org/tenant/orchestrator_/t/status/status-token?sig=abc', ERROR_FUNCTION_NOT_FOUND: 'Function not found', } as const; From b2eab8ce55d693aa16d1f90d7eda45e4f1f4cb29 Mon Sep 17 00:00:00 2001 From: Amrit Agarwal Date: Tue, 28 Jul 2026 19:58:31 +0530 Subject: [PATCH 4/5] refactor(functions): remove dead code from the Functions PR [PLT-106037] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops machinery left behind by the abandoned 303 long-poll invoke approach and tightens the surface the PR adds, so the diff reflects only what the feature needs. Shared core (reverts unused additions to the HTTP layer): - RESPONSE_TYPES.RAW, the 'raw' member of ResponseType, and the RAW passthrough branch in ApiClient — nothing ever set responseType 'raw'. The platform long-polls the job server-side and returns the output as the response body, so no caller drives a redirect chain. - RequestSpec.redirect and its `redirect: options.redirect` fetch passthrough — no call site ever set it. Functions service: - release.id in RawFunctionTrigger: declared but never read, contradicting the type's own "only the fields consumed" contract. - fn.method.toUpperCase() at the invoke call site: BaseService.request() already uppercases the verb. - Private helpers took `options?` and defended with `options ?? {}` / `options?.x` while their only call site always passes a defined object; narrowed to a required FolderScopedOptions. - The "and N more" suffix on the name-lookup error could only ever render "and 1 more" ($top caps the response one row past the limit), so it now says "and more". - resourceType label 'Function.getByName' named a method the service does not expose; now 'Functions.invoke', matching the convention used elsewhere. - The subpath barrel exported the class under both Functions and FunctionService; only the public alias is exported now, matching Notifications and Governance. The unit test imports the class from the implementation file instead. Docs: - Every FunctionServiceModel @example called methods on `fns`, a variable no example declares (the Usage block declares `functions`). Examples now use `functions` throughout, with the getAll result renamed to `deployed` where it would otherwise shadow it. - The module @example used a literal folderId; now the placeholder. Tests: - Deleted functions.browser.test.ts. Its only distinguishing assertion was `expect.not.objectContaining({ redirect: 'manual' })` against the now-removed field, and the service has no platform branch, so the rest duplicated functions.test.ts. - Removed the unused ERROR_FUNCTION_NOT_FOUND test constant. Also drops an unrelated "AI App Builders" mkdocs nav section that leaked into this branch; it points at pages that live on the docs/ai-app-builders branch and would have published five dead nav entries. Co-Authored-By: Claude Opus 4.8 --- src/core/http/api-client.ts | 9 +-- src/models/common/request-spec.ts | 5 +- .../orchestrator/functions.internal-types.ts | 1 - src/models/orchestrator/functions.models.ts | 20 +++---- .../orchestrator/functions/functions.ts | 28 +++++---- src/services/orchestrator/functions/index.ts | 4 +- src/utils/constants/headers.ts | 3 +- .../orchestrator/functions.browser.test.ts | 60 ------------------- .../services/orchestrator/functions.test.ts | 2 +- tests/utils/constants/functions.ts | 1 - 10 files changed, 31 insertions(+), 102 deletions(-) delete mode 100644 tests/unit/services/orchestrator/functions.browser.test.ts diff --git a/src/core/http/api-client.ts b/src/core/http/api-client.ts index 7bc9ba6e9..5b33391b9 100644 --- a/src/core/http/api-client.ts +++ b/src/core/http/api-client.ts @@ -106,16 +106,9 @@ export class ApiClient { method, headers, body, - signal: options.signal, - redirect: options.redirect + signal: options.signal }); - // Raw response type: hand the Response back untouched (no ok-check, no body - // parsing) for callers that drive the protocol themselves (e.g. 303 long-poll chains). - if (options.responseType === RESPONSE_TYPES.RAW) { - return response as T; - } - if (!response.ok) { const errorInfo = await errorResponseParser.parse(response); throw ErrorFactory.createFromHttpStatus(response.status, errorInfo); diff --git a/src/models/common/request-spec.ts b/src/models/common/request-spec.ts index 8280e9c16..2504c99df 100644 --- a/src/models/common/request-spec.ts +++ b/src/models/common/request-spec.ts @@ -8,7 +8,7 @@ export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | /** * Supported response types for API requests */ -export type ResponseType = 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream' | 'raw'; +export type ResponseType = 'json' | 'text' | 'blob' | 'arraybuffer' | 'stream'; /** * Query parameters type with support for arrays and nested objects @@ -102,9 +102,6 @@ export interface RequestSpec { /** AbortSignal for cancelling the request */ signal?: AbortSignal; - /** Redirect handling passed to fetch; 'manual' surfaces 3xx responses to the caller (requires responseType 'raw') */ - redirect?: RequestRedirect; - /** Pagination metadata for the request */ pagination?: PaginationMetadata; } diff --git a/src/models/orchestrator/functions.internal-types.ts b/src/models/orchestrator/functions.internal-types.ts index 1081ef528..dc1e6fc1b 100644 --- a/src/models/orchestrator/functions.internal-types.ts +++ b/src/models/orchestrator/functions.internal-types.ts @@ -33,7 +33,6 @@ export interface RawFunctionTrigger { organizationUnitFullyQualifiedName?: string | null; /** Release (process) that packages the function. */ release: { - id: number; name: string; slug: string; }; diff --git a/src/models/orchestrator/functions.models.ts b/src/models/orchestrator/functions.models.ts index d1509697b..a934a4051 100644 --- a/src/models/orchestrator/functions.models.ts +++ b/src/models/orchestrator/functions.models.ts @@ -43,30 +43,30 @@ export interface FunctionServiceModel { * @example * ```typescript * // Get all functions in a folder - * const functions = await fns.getAll({ folderId: }); + * const deployed = await functions.getAll({ folderId: }); * * // By folder path - * const shared = await fns.getAll({ folderPath: 'Shared/Finance' }); + * const shared = await functions.getAll({ folderPath: 'Shared/Finance' }); * * // With filtering - * const enabled = await fns.getAll({ + * const enabled = await functions.getAll({ * folderId: , * filter: 'enabled eq true', * orderby: 'name asc', * }); * * // Only the functions deployed from one package - * const fromPackage = await fns.getAll({ + * const fromPackage = await functions.getAll({ * folderId: , * filter: "processName eq 'my-functions'", * }); * * // First page with pagination - * const page1 = await fns.getAll({ folderId: , pageSize: 10 }); + * const page1 = await functions.getAll({ folderId: , pageSize: 10 }); * * // Navigate using cursor * if (page1.hasNextPage) { - * const page2 = await fns.getAll({ folderId: , cursor: page1.nextCursor }); + * const page2 = await functions.getAll({ folderId: , cursor: page1.nextCursor }); * } * ``` */ @@ -100,7 +100,7 @@ export interface FunctionServiceModel { * @example * ```typescript * // Invoke a function - * const result = await fns.invoke('hello', { name: 'Alice' }, { folderId: }); + * const result = await functions.invoke('hello', { name: 'Alice' }, { folderId: }); * ``` * * @example @@ -109,7 +109,7 @@ export interface FunctionServiceModel { * interface SyncInput { since: string } * interface SyncOutput { processed: number } * - * const result = await fns.invoke( + * const result = await functions.invoke( * 'sync-invoices', * { since: '2026-01-01' }, * { folderPath: 'Shared/Finance', jobKey: '' } @@ -138,8 +138,8 @@ export interface FunctionMethods { * * @example * ```typescript - * const functions = await fns.getAll({ folderId: }); - * const hello = functions.items.find(f => f.name === 'hello'); + * const deployed = await functions.getAll({ folderId: }); + * const hello = deployed.items.find(f => f.name === 'hello'); * * if (hello) { * const result = await hello.invoke({ name: 'Alice' }); diff --git a/src/services/orchestrator/functions/functions.ts b/src/services/orchestrator/functions/functions.ts index 331721d26..3822163b8 100644 --- a/src/services/orchestrator/functions/functions.ts +++ b/src/services/orchestrator/functions/functions.ts @@ -6,7 +6,7 @@ import { RawFunctionGetResponse, } from '../../../models/orchestrator/functions.types'; import { RawFolderResponse, RawFunctionTrigger } from '../../../models/orchestrator/functions.internal-types'; -import { CollectionResponse } from '../../../models/common/types'; +import { CollectionResponse, FolderScopedOptions } from '../../../models/common/types'; import { UiPathError } from '../../../core/errors/base'; import { NotFoundError } from '../../../core/errors/not-found'; import { isNotFoundError } from '../../../core/errors/guards'; @@ -99,14 +99,14 @@ export class FunctionService extends FolderScopedService implements FunctionServ */ private async findByName( name: string, - options?: FunctionInvokeOptions + options: FolderScopedOptions ): Promise { try { return await this.getByNameLookup, RawFunctionGetResponse>( 'Function', FUNCTION_ENDPOINTS.GET_ALL, name, - options ?? {}, + options, (raw) => this.toFunctionResponse(raw), FunctionMap, ); @@ -125,14 +125,14 @@ export class FunctionService extends FolderScopedService implements FunctionServ */ private async withAvailableFunctionNames( error: NotFoundError, - options?: FunctionInvokeOptions + options: FolderScopedOptions ): Promise { try { const headers = resolveFolderHeaders({ - folderId: options?.folderId, - folderKey: options?.folderKey, - folderPath: options?.folderPath, - resourceType: 'Function.getByName', + folderId: options.folderId, + folderKey: options.folderKey, + folderPath: options.folderPath, + resourceType: 'Functions.invoke', fallbackFolderKey: this.config.folderKey, }); @@ -147,7 +147,9 @@ export class FunctionService extends FolderScopedService implements FunctionServ } const shown = names.slice(0, MAX_SUGGESTED_NAMES).join(', '); - const suffix = names.length > MAX_SUGGESTED_NAMES ? `, and ${names.length - MAX_SUGGESTED_NAMES} more` : ''; + // The lookup asks for one row beyond the cap, so an overflow tells us there are + // more names but not how many. + const suffix = names.length > MAX_SUGGESTED_NAMES ? ', and more' : ''; return new NotFoundError({ message: `${error.message} Available functions: ${shown}${suffix}. Note that a function name is not the name of the package it is deployed from.`, }); @@ -163,12 +165,12 @@ export class FunctionService extends FolderScopedService implements FunctionServ */ private async resolveInvokeFolderKey( fn: RawFunctionGetResponse, - options?: FunctionInvokeOptions + options: FolderScopedOptions ): Promise { - const explicitKey = options?.folderKey?.trim(); + const explicitKey = options.folderKey?.trim(); if (explicitKey) return explicitKey; - const hasExplicitFolder = options?.folderId !== undefined || Boolean(options?.folderPath?.trim()); + const hasExplicitFolder = options.folderId !== undefined || Boolean(options.folderPath?.trim()); if (!hasExplicitFolder && this.config.folderKey) return this.config.folderKey; return this.getFolderKey(fn.folderId); @@ -203,7 +205,7 @@ export class FunctionService extends FolderScopedService implements FunctionServ // verbs receive it as the JSON request body. const response = fn.method === FunctionHttpMethod.Get ? await this.get(endpoint, { params: toQueryParams(input), headers }) - : await this.request(fn.method.toUpperCase(), endpoint, { body: input, headers }); + : await this.request(fn.method, endpoint, { body: input, headers }); return response.data; } diff --git a/src/services/orchestrator/functions/index.ts b/src/services/orchestrator/functions/index.ts index f61c6c4e6..86cc641f4 100644 --- a/src/services/orchestrator/functions/index.ts +++ b/src/services/orchestrator/functions/index.ts @@ -15,13 +15,13 @@ * await sdk.initialize(); * * const functions = new Functions(sdk); - * const result = await functions.invoke('hello', { name: 'Alice' }, { folderId: 123 }); + * const result = await functions.invoke('hello', { name: 'Alice' }, { folderId: }); * ``` * * @module */ -export { FunctionService as Functions, FunctionService } from './functions'; +export { FunctionService as Functions } from './functions'; export * from '../../../models/orchestrator/functions.types'; export * from '../../../models/orchestrator/functions.models'; diff --git a/src/utils/constants/headers.ts b/src/utils/constants/headers.ts index adf1889a4..afc2a43cb 100644 --- a/src/utils/constants/headers.ts +++ b/src/utils/constants/headers.ts @@ -26,8 +26,7 @@ export const RESPONSE_TYPES = { JSON: 'json', TEXT: 'text', BLOB: 'blob', - ARRAYBUFFER: 'arraybuffer', - RAW: 'raw' + ARRAYBUFFER: 'arraybuffer' } as const; /** diff --git a/tests/unit/services/orchestrator/functions.browser.test.ts b/tests/unit/services/orchestrator/functions.browser.test.ts deleted file mode 100644 index eda79b8e3..000000000 --- a/tests/unit/services/orchestrator/functions.browser.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -// ===== IMPORTS ===== -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { FunctionService } from '../../../../src/services/orchestrator/functions'; -import { ApiClient } from '../../../../src/core/http/api-client'; -import { createMockRawFunctionTrigger } from '../../../utils/mocks/functions'; -import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; -import { TEST_CONSTANTS } from '../../../utils/constants/common'; -import { FUNCTION_TEST_CONSTANTS } from '../../../utils/constants/functions'; -import { FUNCTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; - -// ===== MOCKING ===== -vi.mock('../../../../src/core/http/api-client'); - -// Browser environment. Invocation must behave exactly as it does in Node — the -// service has no platform branch and never inspects response headers, which a -// browser cannot read on a cross-origin call. -vi.mock('../../../../src/utils/platform', () => ({ isBrowser: true })); - -// ===== TEST SUITE ===== -describe('FunctionService invoke in browser environments', () => { - let functionService: FunctionService; - let mockApiClient: any; - - beforeEach(() => { - const { instance } = createServiceTestDependencies(); - mockApiClient = createMockApiClient(); - - vi.mocked(ApiClient).mockImplementation(function () { return mockApiClient; }); - - functionService = new FunctionService(instance); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should invoke without manual redirect handling', async () => { - mockApiClient.get - .mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }) - .mockResolvedValueOnce({ Key: FUNCTION_TEST_CONSTANTS.FOLDER_KEY }); - mockApiClient.post.mockResolvedValueOnce(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); - - const result = await functionService.invoke( - FUNCTION_TEST_CONSTANTS.NAME, - FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, - { folderId: TEST_CONSTANTS.FOLDER_ID } - ); - - expect(mockApiClient.post).toHaveBeenCalledWith( - FUNCTION_ENDPOINTS.INVOKE( - FUNCTION_TEST_CONSTANTS.FOLDER_KEY, - FUNCTION_TEST_CONSTANTS.PROCESS_SLUG, - FUNCTION_TEST_CONSTANTS.SLUG - ), - FUNCTION_TEST_CONSTANTS.INVOKE_INPUT, - expect.not.objectContaining({ redirect: 'manual' }) - ); - expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); - }); -}); diff --git a/tests/unit/services/orchestrator/functions.test.ts b/tests/unit/services/orchestrator/functions.test.ts index 8e5db54fc..70eca0116 100644 --- a/tests/unit/services/orchestrator/functions.test.ts +++ b/tests/unit/services/orchestrator/functions.test.ts @@ -1,6 +1,6 @@ // ===== IMPORTS ===== import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { FunctionService } from '../../../../src/services/orchestrator/functions'; +import { FunctionService } from '../../../../src/services/orchestrator/functions/functions'; import { ApiClient } from '../../../../src/core/http/api-client'; import { PaginationHelpers } from '../../../../src/utils/pagination/helpers'; import { diff --git a/tests/utils/constants/functions.ts b/tests/utils/constants/functions.ts index b3bfb5ed8..a657eef41 100644 --- a/tests/utils/constants/functions.ts +++ b/tests/utils/constants/functions.ts @@ -19,5 +19,4 @@ export const FUNCTION_TEST_CONSTANTS = { INVOKE_INPUT: { name: 'Alice' }, INVOKE_OUTPUT: { message: 'Hello, Alice!' }, JOB_KEY: '7f3f4bd6-6f2e-4c5a-9d38-6f3f0a1b2c3d', - ERROR_FUNCTION_NOT_FOUND: 'Function not found', } as const; From 12cad94c19535f8d25164484f37089e61f85e73d Mon Sep 17 00:00:00 2001 From: Amrit Agarwal Date: Tue, 28 Jul 2026 20:06:43 +0530 Subject: [PATCH 5/5] refactor(functions): test hygiene, mock types and doc comments [PLT-106037] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - functions.test.ts called vi.unstubAllGlobals() in afterEach while stubbing no globals at all — the repo's only unpaired instance (jobs and conversational-agent each pair one unstub with 4-8 stubs). - RawFunctionTrigger.folderName's "(null on list responses)" qualifier could not discriminate anything: the service only ever reads the HttpTriggers collection, so every response it sees is a list response. - Dropped an orphaned {@link} line TypeDoc would render as stray text. - createMockRawFunctionTrigger typed as Record instead of any. - Added the missing afterEach teardown to the model test. - FunctionMap's comment claimed it mapped responses; it is only used by transformOptions for OData query rewriting. Co-Authored-By: Claude --- src/models/orchestrator/functions.constants.ts | 7 ++++++- src/models/orchestrator/functions.internal-types.ts | 2 +- src/models/orchestrator/functions.models.ts | 1 - tests/unit/models/orchestrator/functions.test.ts | 6 +++++- tests/unit/services/orchestrator/functions.test.ts | 1 - tests/utils/mocks/functions.ts | 4 +++- 6 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/models/orchestrator/functions.constants.ts b/src/models/orchestrator/functions.constants.ts index e3707d4b1..fe8a6f447 100644 --- a/src/models/orchestrator/functions.constants.ts +++ b/src/models/orchestrator/functions.constants.ts @@ -1,5 +1,10 @@ /** - * Field mapping for Function responses (API field → SDK field). + * OData query rewrite map for Functions (API field → SDK field), reversed by + * `transformOptions()` so callers can use SDK field names in `filter`, + * `orderby`, `select`, and `expand`. + * + * Responses are reshaped by `toFunctionResponse()` instead, which flattens the + * nested `Release` entity and drops the job-runner fields a map cannot express. * Semantic renames only — case conversion is handled by `pascalToCamelCaseKeys()`. */ export const FunctionMap = { diff --git a/src/models/orchestrator/functions.internal-types.ts b/src/models/orchestrator/functions.internal-types.ts index dc1e6fc1b..53e80ef9b 100644 --- a/src/models/orchestrator/functions.internal-types.ts +++ b/src/models/orchestrator/functions.internal-types.ts @@ -29,7 +29,7 @@ export interface RawFunctionTrigger { releaseKey: string; /** Numeric ID of the folder the trigger lives in. */ organizationUnitId: number; - /** Fully qualified folder name (null on list responses). */ + /** Fully qualified folder name. */ organizationUnitFullyQualifiedName?: string | null; /** Release (process) that packages the function. */ release: { diff --git a/src/models/orchestrator/functions.models.ts b/src/models/orchestrator/functions.models.ts index a934a4051..d6b4ebf69 100644 --- a/src/models/orchestrator/functions.models.ts +++ b/src/models/orchestrator/functions.models.ts @@ -39,7 +39,6 @@ export interface FunctionServiceModel { * * @param options - Query options including folder scoping (`folderId` / `folderKey` / `folderPath`), filtering, and pagination options * @returns Promise resolving to either an array of functions {@link NonPaginatedResponse}<{@link FunctionGetResponse}> or a {@link PaginatedResponse}<{@link FunctionGetResponse}> when pagination options are used. - * {@link FunctionGetResponse} * @example * ```typescript * // Get all functions in a folder diff --git a/tests/unit/models/orchestrator/functions.test.ts b/tests/unit/models/orchestrator/functions.test.ts index cf592f7dd..ab771c5ac 100644 --- a/tests/unit/models/orchestrator/functions.test.ts +++ b/tests/unit/models/orchestrator/functions.test.ts @@ -1,5 +1,5 @@ // ===== IMPORTS ===== -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createFunctionWithMethods, FunctionServiceModel } from '../../../../src/models/orchestrator/functions.models'; import { createBasicFunction } from '../../../utils/mocks/functions'; import { FUNCTION_TEST_CONSTANTS } from '../../../utils/constants/functions'; @@ -16,6 +16,10 @@ describe('Function Models Unit Tests', () => { }; }); + afterEach(() => { + vi.clearAllMocks(); + }); + describe('createFunctionWithMethods', () => { it('should merge raw data with bound methods', () => { const functionData = createBasicFunction(); diff --git a/tests/unit/services/orchestrator/functions.test.ts b/tests/unit/services/orchestrator/functions.test.ts index 70eca0116..f0690f55e 100644 --- a/tests/unit/services/orchestrator/functions.test.ts +++ b/tests/unit/services/orchestrator/functions.test.ts @@ -45,7 +45,6 @@ describe('FunctionService Unit Tests', () => { afterEach(() => { vi.clearAllMocks(); - vi.unstubAllGlobals(); }); describe('getAll', () => { diff --git a/tests/utils/mocks/functions.ts b/tests/utils/mocks/functions.ts index 3cf7cf7f1..aeb9e2b4a 100644 --- a/tests/utils/mocks/functions.ts +++ b/tests/utils/mocks/functions.ts @@ -7,7 +7,9 @@ import { createMockBaseResponse, createMockCollection } from './core'; * Creates a raw HttpTriggers row as the API returns it (PascalCase wire format, * including job-runner fields the SDK drops). */ -export const createMockRawFunctionTrigger = (overrides: Partial = {}): any => { +export const createMockRawFunctionTrigger = ( + overrides: Record = {} +): Record => { return { Type: 'Http', OrganizationUnitId: TEST_CONSTANTS.FOLDER_ID,