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/core/http/api-client.ts b/src/core/http/api-client.ts index 0850f294a..5b33391b9 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 new file mode 100644 index 000000000..fe8a6f447 --- /dev/null +++ b/src/models/orchestrator/functions.constants.ts @@ -0,0 +1,18 @@ +/** + * 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 = { + 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.internal-types.ts b/src/models/orchestrator/functions.internal-types.ts new file mode 100644 index 000000000..53e80ef9b --- /dev/null +++ b/src/models/orchestrator/functions.internal-types.ts @@ -0,0 +1,48 @@ +/** + * 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. */ + organizationUnitFullyQualifiedName?: string | null; + /** Release (process) that packages the function. */ + release: { + 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..d6b4ebf69 --- /dev/null +++ b/src/models/orchestrator/functions.models.ts @@ -0,0 +1,188 @@ +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. + * + * 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. + * @example + * ```typescript + * // Get all functions in a folder + * const deployed = await functions.getAll({ folderId: }); + * + * // By folder path + * const shared = await functions.getAll({ folderPath: 'Shared/Finance' }); + * + * // With filtering + * const enabled = await functions.getAll({ + * folderId: , + * filter: 'enabled eq true', + * orderby: 'name asc', + * }); + * + * // Only the functions deployed from one package + * const fromPackage = await functions.getAll({ + * folderId: , + * filter: "processName eq 'my-functions'", + * }); + * + * // First page with pagination + * const page1 = await functions.getAll({ folderId: , pageSize: 10 }); + * + * // Navigate using cursor + * if (page1.hasNextPage) { + * const page2 = await functions.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 — 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 + * 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`) and + * parent job attribution (`jobKey`) + * @returns Promise resolving to the function's output + * + * @example + * ```typescript + * // Invoke a function + * const result = await functions.invoke('hello', { name: 'Alice' }, { folderId: }); + * ``` + * + * @example + * ```typescript + * // Typed input and output, attributed to a parent job's licensing transaction + * interface SyncInput { since: string } + * interface SyncOutput { processed: number } + * + * const result = await functions.invoke( + * 'sync-invoices', + * { since: '2026-01-01' }, + * { folderPath: 'Shared/Finance', jobKey: '' } + * ); + * console.log(result.processed); + * ``` + */ + 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 deployed = await functions.getAll({ folderId: }); + * const hello = deployed.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..8e1ea654c --- /dev/null +++ b/src/models/orchestrator/functions.types.ts @@ -0,0 +1,74 @@ +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 { + /** + * 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/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..3822163b8 --- /dev/null +++ b/src/services/orchestrator/functions/functions.ts @@ -0,0 +1,242 @@ +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 { 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'; +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 { JOB_KEY } from '../../../utils/constants/headers'; +import { createHeaders } from '../../../utils/http/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'; + +/** Cap on the function names listed when a name lookup misses. */ +const MAX_SUGGESTED_NAMES = 20; + +/** + * 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 { + // 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 ?? {}, jobKey); + } + + /** + * 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: FolderScopedOptions + ): Promise { + 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: FolderScopedOptions + ): Promise { + try { + const headers = resolveFolderHeaders({ + folderId: options.folderId, + folderKey: options.folderKey, + folderPath: options.folderPath, + resourceType: 'Functions.invoke', + 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(', '); + // 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.`, + }); + } catch { + return error; + } + } + + /** + * 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: FolderScopedOptions + ): 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 answers with its output as the response body. + */ + private async invokeFunction( + fn: RawFunctionGetResponse, + folderKey: string, + input: object, + 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 }); + + // 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), headers }) + : await this.request(fn.method, endpoint, { body: input, headers }); + + 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..86cc641f4 --- /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: }); + * ``` + * + * @module + */ + +export { FunctionService as Functions } 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..56f19cbbb --- /dev/null +++ b/tests/integration/shared/orchestrator/functions.integration.test.ts @@ -0,0 +1,131 @@ +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 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) { + 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..ab771c5ac --- /dev/null +++ b/tests/unit/models/orchestrator/functions.test.ts @@ -0,0 +1,99 @@ +// ===== IMPORTS ===== +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'; +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(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + 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..f0690f55e --- /dev/null +++ b/tests/unit/services/orchestrator/functions.test.ts @@ -0,0 +1,494 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { FunctionService } from '../../../../src/services/orchestrator/functions/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, JOB_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 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(); + + 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 send the X-UIPATH-JobKey header on the invocation when jobKey is provided', async () => { + mockApiClient.get.mockResolvedValueOnce({ value: [createMockRawFunctionTrigger()] }); + mockApiClient.post.mockResolvedValueOnce(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(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); + + 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, jobKey: FUNCTION_TEST_CONSTANTS.JOB_KEY } + ); + + 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, + headers: expect.objectContaining({ [JOB_KEY]: FUNCTION_TEST_CONSTANTS.JOB_KEY }), + }) + ); + expect(result).toEqual(FUNCTION_TEST_CONSTANTS.INVOKE_OUTPUT); + }); + + it('should throw NotFoundError when the function does not exist in the folder', async () => { + // Name lookup misses, then the folder's function names are listed for the error. + mockApiClient.get + .mockResolvedValueOnce({ value: [] }) + .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 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 }], + }); + + // 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 say so when the folder exposes no functions at all', async () => { + mockApiClient.get + .mockResolvedValueOnce({ value: [] }) + .mockResolvedValueOnce({ value: [] }); + + 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 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, + { folderId: TEST_CONSTANTS.FOLDER_ID } + ) + ).rejects.toThrow(NotFoundError); + }); + + 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..a657eef41 --- /dev/null +++ b/tests/utils/constants/functions.ts @@ -0,0 +1,22 @@ +/** + * Test constants for Functions service tests + */ +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.', + 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!' }, + JOB_KEY: '7f3f4bd6-6f2e-4c5a-9d38-6f3f0a1b2c3d', +} as const; diff --git a/tests/utils/mocks/functions.ts b/tests/utils/mocks/functions.ts new file mode 100644 index 000000000..aeb9e2b4a --- /dev/null +++ b/tests/utils/mocks/functions.ts @@ -0,0 +1,94 @@ +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: Record = {} +): Record => { + 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 }), + }); +};