From 4077cfae379979cf8e00c55ab3cd6158242bd308 Mon Sep 17 00:00:00 2001 From: maninder-uipath Date: Mon, 29 Jun 2026 14:00:34 +0530 Subject: [PATCH 1/5] feat(integration-service): add Connections service [JAR-IS-1] Adds the Integration Service Connections service (getAll, getById, ping, reauthenticate) exposed via the @uipath/uipath-typescript/is-connections subpath, with unit + model tests, endpoint constants, OAuth scope docs, and mkdocs nav. Co-Authored-By: Claude Opus 4.8 --- docs/oauth-scopes.md | 9 + mkdocs.yml | 2 + package.json | 10 + rollup.config.js | 5 + .../integration-service/connections.models.ts | 220 ++++++++++++++++++ .../integration-service/connections.types.ts | 185 +++++++++++++++ src/models/integration-service/index.ts | 8 + .../connections/connections.ts | 207 ++++++++++++++++ .../integration-service/connections/index.ts | 22 ++ src/utils/constants/endpoints/base.ts | 1 + src/utils/constants/endpoints/index.ts | 3 + .../endpoints/integration-service.ts | 14 ++ .../integration-service/connections.test.ts | 118 ++++++++++ .../integration-service/connections.test.ts | 198 ++++++++++++++++ tests/utils/constants/index.ts | 1 + tests/utils/constants/integration-service.ts | 26 +++ tests/utils/mocks/index.ts | 1 + tests/utils/mocks/integration-service.ts | 51 ++++ 18 files changed, 1081 insertions(+) create mode 100644 src/models/integration-service/connections.models.ts create mode 100644 src/models/integration-service/connections.types.ts create mode 100644 src/models/integration-service/index.ts create mode 100644 src/services/integration-service/connections/connections.ts create mode 100644 src/services/integration-service/connections/index.ts create mode 100644 src/utils/constants/endpoints/integration-service.ts create mode 100644 tests/unit/models/integration-service/connections.test.ts create mode 100644 tests/unit/services/integration-service/connections.test.ts create mode 100644 tests/utils/constants/integration-service.ts create mode 100644 tests/utils/mocks/integration-service.ts diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 783259d6f..b0d0a70c0 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -2,6 +2,15 @@ This page lists the specific OAuth scopes required in external app for each SDK method. +## Integration Service — Connections + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `ConnectionService` or `ConnectionServiceUser` | +| `getById()` | `ConnectionService` or `ConnectionServiceUser` | +| `ping()` | `ConnectionService` or `ConnectionServiceUser` | +| `reauthenticate()` | `ConnectionService` | + ## Assets | Method | OAuth Scope | diff --git a/mkdocs.yml b/mkdocs.yml index dbe06530a..6798c6e56 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -199,6 +199,8 @@ nav: - api/interfaces/entity/index.md - Choice Sets: api/interfaces/ChoiceSetServiceModel.md - Governance: api/interfaces/GovernanceServiceModel.md + - Integration Service: + - Connections: api/interfaces/ConnectionsServiceModel.md - Maestro: - Processes: api/interfaces/MaestroProcessesServiceModel.md - Process Instances: api/interfaces/ProcessInstancesServiceModel.md diff --git a/package.json b/package.json index c6525348d..e6208783e 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,16 @@ "types": "./dist/governance/index.d.ts", "default": "./dist/governance/index.cjs" } + }, + "./is-connections": { + "import": { + "types": "./dist/is-connections/index.d.ts", + "default": "./dist/is-connections/index.mjs" + }, + "require": { + "types": "./dist/is-connections/index.d.ts", + "default": "./dist/is-connections/index.cjs" + } } }, "files": [ diff --git a/rollup.config.js b/rollup.config.js index aa42cc39b..9e60edff5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -228,6 +228,11 @@ const serviceEntries = [ name: 'governance', input: 'src/services/governance/index.ts', output: 'governance/index' + }, + { + name: 'is-connections', + input: 'src/services/integration-service/connections/index.ts', + output: 'is-connections/index' } ]; diff --git a/src/models/integration-service/connections.models.ts b/src/models/integration-service/connections.models.ts new file mode 100644 index 000000000..356893f1f --- /dev/null +++ b/src/models/integration-service/connections.models.ts @@ -0,0 +1,220 @@ +/** + * Integration Service — Connection models + * + * Combines raw connection data with bound entity methods (`ping`, `reauthenticate`). + */ + +import { + RawConnectionGetResponse, + ConnectionGetAllOptions, + ConnectionGetByIdOptions, + ConnectionPingOptions, + ConnectionPingResponse, + ConnectionReauthenticateOptions, + ConnectionReauthenticateResponse, +} from './connections.types'; + +/** + * A Connection entity enriched with bound methods. + * + * Returned by every Connection-yielding method on the {@link ConnectionsServiceModel} + * and {@link ConnectorsServiceModel}. The bound methods (`ping`, `reauthenticate`) + * close over this connection's ID so callers can act on the entity directly. + */ +export type ConnectionGetResponse = RawConnectionGetResponse & ConnectionMethods; + +/** + * Service for managing UiPath Integration Service connections. + * + * A connection represents an authenticated link to a third-party system (Salesforce, + * Slack, OneDrive, ...) inside a UiPath folder. Use this service to list connections, + * inspect a single connection, check connectivity, or trigger re-authentication. + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * const allConnections = await connections.getAll(); + * ``` + */ +export interface ConnectionsServiceModel { + /** + * Get all connections, optionally scoped to a folder. + * + * Returns a plain array of connection entities. Pagination is page-indexed + * via `pageIndex`/`pageSize`; there is no continuation cursor, so callers + * paginate by incrementing `pageIndex` until a short page is returned. + * + * @param options - Folder scoping, paging, sorting, and filter options + * @returns Promise resolving to an array of {@link ConnectionGetResponse} + * @example + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * + * // List the first page of connections in a folder + * const folderConnections = await connections.getAll({ + * folderKey: '', + * pageSize: 50, + * }); + * + * for (const conn of folderConnections) { + * console.log(`${conn.name} (${conn.state})`); + * } + * ``` + * + * @example + * ```typescript + * // Filter by name and connector + * const filtered = await connections.getAll({ + * folderKey: '', + * filter: "connector.key eq 'uipath-slack'", + * mostRecentFirst: true, + * }); + * ``` + */ + getAll(options?: ConnectionGetAllOptions): Promise; + + /** + * Get a single connection by ID. + * + * @param connectionId - Connection GUID + * @param options - Folder scoping and optional `includeConfigs` flag + * @returns Promise resolving to a {@link ConnectionGetResponse} + * @example + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * + * // First, list connections to find the connectionId + * const list = await connections.getAll({ folderKey: '' }); + * const connectionId = list[0].id; + * + * const conn = await connections.getById(connectionId); + * console.log(conn.connector?.key, conn.state); + * ``` + * + * @example + * ```typescript + * // Include the full configuration blob + * const conn = await connections.getById('', { includeConfigs: true }); + * ``` + */ + getById(connectionId: string, options?: ConnectionGetByIdOptions): Promise; + + /** + * Check whether a connection is currently active. + * + * Returns the resolved state plus an optional error message. Use this before + * invoking activities to surface a friendly error when the connection has + * expired or been disabled. + * + * @param connectionId - Connection GUID + * @param options - Folder scoping and `forceRefresh` flag + * @returns Promise resolving to a {@link ConnectionPingResponse} + * @example + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * + * const status = await connections.ping(''); + * if (status.status !== 'Enabled') { + * console.warn(`Connection unhealthy: ${status.status} — ${status.error ?? 'no detail'}`); + * } + * ``` + * + * @example + * ```typescript + * // Skip cache and force a live re-validation + * const status = await connections.ping('', { forceRefresh: true }); + * ``` + */ + ping(connectionId: string, options?: ConnectionPingOptions): Promise; + + /** + * Start an OAuth re-authentication session for a connection. + * + * Returns a session handle plus the URL the end user must visit to grant or + * refresh consent. The session expires at {@link ConnectionReauthenticateResponse.expiresAt}. + * + * @param connectionId - Connection GUID + * @param options - Folder scoping options + * @returns Promise resolving to a {@link ConnectionReauthenticateResponse} + * @example + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * + * const session = await connections.reauthenticate(''); + * // Direct the user to session.authUrl to complete OAuth consent. + * console.log(`Visit: ${session.authUrl}`); + * ``` + */ + reauthenticate( + connectionId: string, + options?: ConnectionReauthenticateOptions, + ): Promise; +} + +/** + * Methods bound onto every {@link ConnectionGetResponse} entity. + * + * Each method closes over the connection's ID and delegates to the + * underlying service. + */ +export interface ConnectionMethods { + /** + * Check whether this connection is currently active. + * + * @param options - Optional `forceRefresh` flag and folder scoping + * @returns Promise resolving to a {@link ConnectionPingResponse} + */ + ping(options?: ConnectionPingOptions): Promise; + + /** + * Start an OAuth re-authentication session for this connection. + * + * @param options - Optional folder scoping + * @returns Promise resolving to a {@link ConnectionReauthenticateResponse} + */ + reauthenticate(options?: ConnectionReauthenticateOptions): Promise; +} + +function createConnectionMethods( + data: RawConnectionGetResponse, + service: ConnectionsServiceModel, +): ConnectionMethods { + return { + async ping(options?: ConnectionPingOptions): Promise { + if (!data.id) throw new Error('Connection id is undefined'); + return service.ping(data.id, options); + }, + async reauthenticate(options?: ConnectionReauthenticateOptions): Promise { + if (!data.id) throw new Error('Connection id is undefined'); + return service.reauthenticate(data.id, options); + }, + }; +} + +/** + * Attaches bound methods to a raw connection response. + * + * @param data - Raw connection data from the API + * @param service - The Connections service used to delegate bound-method calls + * @returns A {@link ConnectionGetResponse} (raw data + methods) + */ +export function createConnectionWithMethods( + data: RawConnectionGetResponse, + service: ConnectionsServiceModel, +): ConnectionGetResponse { + const methods = createConnectionMethods(data, service); + return Object.assign({}, data, methods) as ConnectionGetResponse; +} diff --git a/src/models/integration-service/connections.types.ts b/src/models/integration-service/connections.types.ts new file mode 100644 index 000000000..137b1dd01 --- /dev/null +++ b/src/models/integration-service/connections.types.ts @@ -0,0 +1,185 @@ +/** + * Integration Service — Connection types + * + * Types for the Connections API (`connections_/api/v1/Connections`). + */ + +/** + * Lifecycle state of an Integration Service connection. + */ +export enum ConnectionState { + /** Connection is healthy and authorized. */ + Enabled = 'Enabled', + /** Connection has been administratively disabled. */ + Disabled = 'Disabled', + /** Token expired and needs re-authentication. */ + Expired = 'Expired', + /** Connection failed validation or upstream error. */ + Failed = 'Failed', +} + +/** + * Folder context associated with a connection. + */ +export interface ConnectionFolder { + /** Folder GUID. */ + key: string; + /** Folder display name. */ + displayName: string; + /** Slash-delimited folder path. */ + fullyQualifiedName?: string; + /** Folder kind (Personal, Standard, ...). */ + folderType?: string; +} + +/** + * Connector metadata embedded in a connection response. + * + * The full {@link ConnectorGetResponse} shape is nested on every connection; + * we expose its commonly-used fields here so callers don't need a separate + * lookup for basics like the connector key and display name. + */ +export interface ConnectionConnectorRef { + /** Numeric connector ID. */ + id: number; + /** Connector key (used as `elementKey` for Elements API calls). */ + key: string; + /** Display name of the connector (e.g. "Slack", "Microsoft OneDrive"). */ + name: string; + /** Connector lifecycle stage (e.g. `GA`, `BETA`, `CUSTOM`). */ + lifeCycleStage?: string; + /** Connector tier (e.g. `1`, `2`). */ + tier?: string; + /** Whether this connector publishes events / triggers. */ + hasEvents?: boolean; + /** Whether the connector is private (custom or org-scoped). */ + isPrivate?: boolean; +} + +/** + * Raw connection response from the Integration Service API. + */ +export interface RawConnectionGetResponse { + /** Connection ID (GUID). */ + id: string; + /** User-supplied connection name. */ + name: string; + /** Email or principal that created the connection. */ + owner?: string; + /** ISO timestamp the connection was created (UTC). */ + createTime: string; + /** ISO timestamp of the last modification (UTC). */ + updateTime: string; + /** Current lifecycle state. */ + state: ConnectionState; + /** Resolved API base URI used when invoking this connection. */ + apiBaseUri?: string; + /** Cloud Elements instance ID backing this connection. */ + elementInstanceId: number; + /** Connector metadata snapshot. */ + connector?: ConnectionConnectorRef; + /** Whether this connection is the default for its connector + folder. */ + isDefault: boolean; + /** Last time the connection was used at runtime. */ + lastUsedTime?: string; + /** Stable identifier for the authenticated principal (e.g. external user ID). */ + connectionIdentity?: string; + /** Poll interval in minutes for trigger-based event sourcing. */ + pollingIntervalInMinutes?: number; + /** Folder context. */ + folder?: ConnectionFolder; + /** Reason the connection was disabled (if `state === Disabled`). */ + disabledReason?: string; + /** Version of the connector element backing this connection. */ + elementVersion?: string; + /** Whether this is a Bring-Your-Own-Auth connection. */ + byoaConnection?: boolean; +} + +/** + * Options for {@link ConnectionsServiceModel.getAll}. + * + * The API returns a plain array — pagination is page-indexed via `pageIndex`/`pageSize`. + * There is no continuation cursor; callers paginate by incrementing `pageIndex`. + */ +export interface ConnectionGetAllOptions { + /** Folder key (GUID) to scope the query to a specific folder. Sent as `x-uipath-folderkey` header. */ + folderKey?: string; + /** Include connections from all folders the caller has access to. */ + allFolders?: boolean; + /** Include only default connections (one per connector per folder). */ + folderDefaults?: boolean; + /** 1-indexed page number. */ + pageIndex?: number; + /** Page size (number of items per page). */ + pageSize?: number; + /** Sort newest-first. */ + mostRecentFirst?: boolean; + /** Server-side filter expression. */ + filter?: string; + /** Force connector refresh and skip cache. */ + fetchLatest?: boolean; +} + +/** + * Options for {@link ConnectionsServiceModel.getById}. + */ +export interface ConnectionGetByIdOptions { + /** Folder key (GUID) to scope the lookup. */ + folderKey?: string; + /** Search across folders the caller has access to. */ + allFolders?: boolean; + /** Include the connector's full configuration blob in the response. */ + includeConfigs?: boolean; +} + +/** + * Options for {@link ConnectionsServiceModel.ping}. + */ +export interface ConnectionPingOptions { + /** Folder key (GUID) to scope the lookup. */ + folderKey?: string; + /** Search across folders the caller has access to. */ + allFolders?: boolean; + /** Force a live re-validation instead of cached status. */ + forceRefresh?: boolean; +} + +/** + * Response from {@link ConnectionsServiceModel.ping}. + */ +export interface ConnectionPingResponse { + /** Connector key for the pinged connection. */ + connector: string; + /** Current connection state. */ + status: ConnectionState; + /** Error message if the ping failed. */ + error?: string; +} + +/** + * Options for {@link ConnectionsServiceModel.reauthenticate}. + */ +export interface ConnectionReauthenticateOptions { + /** Folder key (GUID) to scope the operation. */ + folderKey?: string; + /** Search across folders the caller has access to. */ + allFolders?: boolean; +} + +/** + * Response from {@link ConnectionsServiceModel.reauthenticate}. + * + * Re-authentication is a multi-step OAuth flow — the SDK returns the session + * handle and the auth URL the user must visit to grant consent. + */ +export interface ConnectionReauthenticateResponse { + /** Connector key for the connection being re-authenticated. */ + connector: string; + /** Session ID used to poll for auth completion. */ + sessionId: string; + /** Epoch milliseconds when the auth session expires. */ + expiresAt: number; + /** URL the user must visit to grant or refresh OAuth consent. */ + authUrl: string; +} diff --git a/src/models/integration-service/index.ts b/src/models/integration-service/index.ts new file mode 100644 index 000000000..9eb215d37 --- /dev/null +++ b/src/models/integration-service/index.ts @@ -0,0 +1,8 @@ +/** + * Integration Service models barrel. + * + * Internal-types files are intentionally not re-exported. + */ + +export * from './connections.types'; +export * from './connections.models'; diff --git a/src/services/integration-service/connections/connections.ts b/src/services/integration-service/connections/connections.ts new file mode 100644 index 000000000..62ff24e49 --- /dev/null +++ b/src/services/integration-service/connections/connections.ts @@ -0,0 +1,207 @@ +import { BaseService } from '../../base'; +import { track } from '../../../core/telemetry'; +import { ValidationError } from '../../../core/errors'; +import { createHeaders } from '../../../utils/http/headers'; +import { FOLDER_KEY } from '../../../utils/constants/headers'; +import { CONNECTION_ENDPOINTS } from '../../../utils/constants/endpoints'; +import { QueryParams } from '../../../models/common/request-spec'; +import { + ConnectionGetAllOptions, + ConnectionGetByIdOptions, + ConnectionPingOptions, + ConnectionPingResponse, + ConnectionReauthenticateOptions, + ConnectionReauthenticateResponse, + RawConnectionGetResponse, +} from '../../../models/integration-service/connections.types'; +import { + ConnectionGetResponse, + ConnectionsServiceModel, + createConnectionWithMethods, +} from '../../../models/integration-service/connections.models'; + +/** + * Service for managing UiPath Integration Service connections. + * + * A connection represents an authenticated link to a third-party system (Salesforce, + * Slack, OneDrive, ...) inside a UiPath folder. Use this service to list connections, + * inspect a single connection, check connectivity, or trigger re-authentication. + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * const allConnections = await connections.getAll(); + * ``` + */ +export class ConnectionsService extends BaseService implements ConnectionsServiceModel { + /** + * Get all connections, optionally scoped to a folder. + * + * Returns a plain array of connection entities. Pagination is page-indexed + * via `pageIndex`/`pageSize`; there is no continuation cursor, so callers + * paginate by incrementing `pageIndex` until a short page is returned. + * + * @param options - Folder scoping, paging, sorting, and filter options + * @returns Promise resolving to an array of {@link ConnectionGetResponse} + * @example + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * + * // List the first page of connections in a folder + * const folderConnections = await connections.getAll({ + * folderKey: '', + * pageSize: 50, + * }); + * + * for (const conn of folderConnections) { + * console.log(`${conn.name} (${conn.state})`); + * } + * ``` + * + * @example + * ```typescript + * // Filter by name and connector + * const filtered = await connections.getAll({ + * folderKey: '', + * filter: "connector.key eq 'uipath-slack'", + * mostRecentFirst: true, + * }); + * ``` + */ + @track('Connections.GetAll') + async getAll(options?: ConnectionGetAllOptions): Promise { + const { folderKey, ...queryOptions } = options ?? {}; + const response = await this.get(CONNECTION_ENDPOINTS.GET_ALL, { + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + params: queryOptions as QueryParams, + }); + return (response.data ?? []).map((conn) => createConnectionWithMethods(conn, this)); + } + + /** + * Get a single connection by ID. + * + * @param connectionId - Connection GUID + * @param options - Folder scoping and optional `includeConfigs` flag + * @returns Promise resolving to a {@link ConnectionGetResponse} + * @example + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * + * // First, list connections to find the connectionId + * const list = await connections.getAll({ folderKey: '' }); + * const connectionId = list[0].id; + * + * const conn = await connections.getById(connectionId); + * console.log(conn.connector?.key, conn.state); + * ``` + * + * @example + * ```typescript + * // Include the full configuration blob + * const conn = await connections.getById('', { includeConfigs: true }); + * ``` + */ + @track('Connections.GetById') + async getById(connectionId: string, options?: ConnectionGetByIdOptions): Promise { + if (!connectionId) { + throw new ValidationError({ message: 'connectionId is required for getById' }); + } + const { folderKey, ...queryOptions } = options ?? {}; + const response = await this.get(CONNECTION_ENDPOINTS.GET_BY_ID(connectionId), { + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + params: queryOptions as QueryParams, + }); + return createConnectionWithMethods(response.data, this); + } + + /** + * Check whether a connection is currently active. + * + * Returns the resolved state plus an optional error message. Use this before + * invoking activities to surface a friendly error when the connection has + * expired or been disabled. + * + * @param connectionId - Connection GUID + * @param options - Folder scoping and `forceRefresh` flag + * @returns Promise resolving to a {@link ConnectionPingResponse} + * @example + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * + * const status = await connections.ping(''); + * if (status.status !== 'Enabled') { + * console.warn(`Connection unhealthy: ${status.status} — ${status.error ?? 'no detail'}`); + * } + * ``` + * + * @example + * ```typescript + * // Skip cache and force a live re-validation + * const status = await connections.ping('', { forceRefresh: true }); + * ``` + */ + @track('Connections.Ping') + async ping(connectionId: string, options?: ConnectionPingOptions): Promise { + if (!connectionId) { + throw new ValidationError({ message: 'connectionId is required for ping' }); + } + const { folderKey, ...queryOptions } = options ?? {}; + const response = await this.get(CONNECTION_ENDPOINTS.PING(connectionId), { + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + params: queryOptions as QueryParams, + }); + return response.data; + } + + /** + * Start an OAuth re-authentication session for a connection. + * + * Returns a session handle plus the URL the end user must visit to grant or + * refresh consent. The session expires at {@link ConnectionReauthenticateResponse.expiresAt}. + * + * @param connectionId - Connection GUID + * @param options - Folder scoping options + * @returns Promise resolving to a {@link ConnectionReauthenticateResponse} + * @example + * ```typescript + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const connections = new Connections(sdk); + * + * const session = await connections.reauthenticate(''); + * // Direct the user to session.authUrl to complete OAuth consent. + * console.log(`Visit: ${session.authUrl}`); + * ``` + */ + @track('Connections.Reauthenticate') + async reauthenticate( + connectionId: string, + options?: ConnectionReauthenticateOptions, + ): Promise { + if (!connectionId) { + throw new ValidationError({ message: 'connectionId is required for reauthenticate' }); + } + const { folderKey, ...queryOptions } = options ?? {}; + const response = await this.post( + CONNECTION_ENDPOINTS.REAUTHENTICATE(connectionId), + undefined, + { + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + params: queryOptions as QueryParams, + }, + ); + return response.data; + } +} diff --git a/src/services/integration-service/connections/index.ts b/src/services/integration-service/connections/index.ts new file mode 100644 index 000000000..740b89535 --- /dev/null +++ b/src/services/integration-service/connections/index.ts @@ -0,0 +1,22 @@ +/** + * Integration Service — Connections module. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Connections } from '@uipath/uipath-typescript/is-connections'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const connections = new Connections(sdk); + * const all = await connections.getAll({ folderKey: '' }); + * ``` + * + * @module + */ + +export { ConnectionsService as Connections, ConnectionsService } from './connections'; + +export * from '../../../models/integration-service/connections.types'; +export * from '../../../models/integration-service/connections.models'; diff --git a/src/utils/constants/endpoints/base.ts b/src/utils/constants/endpoints/base.ts index 6bfa8f393..a6977204f 100644 --- a/src/utils/constants/endpoints/base.ts +++ b/src/utils/constants/endpoints/base.ts @@ -9,3 +9,4 @@ export const IDENTITY_BASE = 'identity_'; export const AUTOPILOT_BASE = 'autopilotforeveryone_'; export const LLMOPS_BASE = 'llmopstenant_'; export const INSIGHTS_RTM_BASE = 'insightsrtm_'; +export const CONNECTIONS_BASE = 'connections_'; diff --git a/src/utils/constants/endpoints/index.ts b/src/utils/constants/endpoints/index.ts index 1b9c6c596..bff537310 100644 --- a/src/utils/constants/endpoints/index.ts +++ b/src/utils/constants/endpoints/index.ts @@ -38,3 +38,6 @@ export * from './agents'; // Governance endpoints export * from './governance'; + +// Integration Service endpoints +export * from './integration-service'; diff --git a/src/utils/constants/endpoints/integration-service.ts b/src/utils/constants/endpoints/integration-service.ts new file mode 100644 index 000000000..0d95e6040 --- /dev/null +++ b/src/utils/constants/endpoints/integration-service.ts @@ -0,0 +1,14 @@ +/** + * Integration Service Endpoints + * + * `connections_` domain — connectors and connections (CONNECTOR_ENDPOINTS, CONNECTION_ENDPOINTS). + */ + +import { CONNECTIONS_BASE } from './base'; + +export const CONNECTION_ENDPOINTS = { + GET_ALL: `${CONNECTIONS_BASE}/api/v1/Connections`, + GET_BY_ID: (connectionId: string) => `${CONNECTIONS_BASE}/api/v1/Connections/${encodeURIComponent(connectionId)}`, + PING: (connectionId: string) => `${CONNECTIONS_BASE}/api/v1/Connections/${encodeURIComponent(connectionId)}/ping`, + REAUTHENTICATE: (connectionId: string) => `${CONNECTIONS_BASE}/api/v1/Connections/${encodeURIComponent(connectionId)}/auth`, +} as const; diff --git a/tests/unit/models/integration-service/connections.test.ts b/tests/unit/models/integration-service/connections.test.ts new file mode 100644 index 000000000..2b0605cdf --- /dev/null +++ b/tests/unit/models/integration-service/connections.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createConnectionWithMethods, + ConnectionsServiceModel, +} from '../../../../src/models/integration-service/connections.models'; +import { ConnectionState } from '../../../../src/models/integration-service/connections.types'; +import { IS_TEST_CONSTANTS, createMockConnection } from '../../../utils/mocks'; + +describe('Connection bound methods', () => { + let mockService: ConnectionsServiceModel; + + beforeEach(() => { + mockService = { + getAll: vi.fn(), + getById: vi.fn(), + ping: vi.fn(), + reauthenticate: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('connection.ping()', () => { + it('should delegate to service.ping with bound connection id', async () => { + const connection = createConnectionWithMethods(createMockConnection(), mockService); + const mockResponse = { + connector: IS_TEST_CONSTANTS.CONNECTOR_KEY, + status: ConnectionState.Enabled, + }; + mockService.ping = vi.fn().mockResolvedValue(mockResponse); + + const result = await connection.ping(); + + expect(mockService.ping).toHaveBeenCalledWith(IS_TEST_CONSTANTS.CONNECTION_ID, undefined); + expect(result).toBe(mockResponse); + }); + + it('should pass options through', async () => { + const connection = createConnectionWithMethods(createMockConnection(), mockService); + mockService.ping = vi.fn().mockResolvedValue({ + connector: IS_TEST_CONSTANTS.CONNECTOR_KEY, + status: ConnectionState.Enabled, + }); + + await connection.ping({ forceRefresh: true }); + + expect(mockService.ping).toHaveBeenCalledWith(IS_TEST_CONSTANTS.CONNECTION_ID, { + forceRefresh: true, + }); + }); + + it('should throw when the underlying connection has no id', async () => { + const connection = createConnectionWithMethods( + createMockConnection({ id: '' as unknown as string }), + mockService, + ); + await expect(connection.ping()).rejects.toThrow('Connection id is undefined'); + }); + }); + + describe('connection.reauthenticate()', () => { + it('should delegate to service.reauthenticate with bound id', async () => { + const connection = createConnectionWithMethods(createMockConnection(), mockService); + const mockResponse = { + connector: IS_TEST_CONSTANTS.CONNECTOR_KEY, + sessionId: IS_TEST_CONSTANTS.AUTH_SESSION_ID, + expiresAt: IS_TEST_CONSTANTS.AUTH_EXPIRES_AT, + authUrl: IS_TEST_CONSTANTS.AUTH_URL, + }; + mockService.reauthenticate = vi.fn().mockResolvedValue(mockResponse); + + const result = await connection.reauthenticate(); + + expect(mockService.reauthenticate).toHaveBeenCalledWith( + IS_TEST_CONSTANTS.CONNECTION_ID, + undefined, + ); + expect(result).toBe(mockResponse); + }); + + it('should pass folderKey through', async () => { + const connection = createConnectionWithMethods(createMockConnection(), mockService); + mockService.reauthenticate = vi.fn().mockResolvedValue({ + connector: IS_TEST_CONSTANTS.CONNECTOR_KEY, + sessionId: IS_TEST_CONSTANTS.AUTH_SESSION_ID, + expiresAt: IS_TEST_CONSTANTS.AUTH_EXPIRES_AT, + authUrl: IS_TEST_CONSTANTS.AUTH_URL, + }); + + await connection.reauthenticate({ folderKey: IS_TEST_CONSTANTS.FOLDER_KEY }); + + expect(mockService.reauthenticate).toHaveBeenCalledWith(IS_TEST_CONSTANTS.CONNECTION_ID, { + folderKey: IS_TEST_CONSTANTS.FOLDER_KEY, + }); + }); + }); + + describe('createConnectionWithMethods', () => { + it('should attach raw data fields verbatim', () => { + const raw = createMockConnection(); + const connection = createConnectionWithMethods(raw, mockService); + + expect(connection.id).toBe(raw.id); + expect(connection.name).toBe(raw.name); + expect(connection.state).toBe(raw.state); + expect(connection.createTime).toBe(raw.createTime); + expect(connection.folder?.key).toBe(raw.folder?.key); + }); + + it('should attach both bound methods', () => { + const connection = createConnectionWithMethods(createMockConnection(), mockService); + expect(typeof connection.ping).toBe('function'); + expect(typeof connection.reauthenticate).toBe('function'); + }); + }); +}); diff --git a/tests/unit/services/integration-service/connections.test.ts b/tests/unit/services/integration-service/connections.test.ts new file mode 100644 index 000000000..5336f9e27 --- /dev/null +++ b/tests/unit/services/integration-service/connections.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ConnectionsService } from '../../../../src/services/integration-service/connections/connections'; +import { CONNECTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { ValidationError } from '../../../../src/core/errors'; +import { FOLDER_KEY } from '../../../../src/utils/constants/headers'; +import { ConnectionState } from '../../../../src/models/integration-service/connections.types'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { + IS_TEST_CONSTANTS, + createMockConnection, + createMockError, +} from '../../../utils/mocks'; + +vi.mock('../../../../src/core/http/api-client'); + +describe('ConnectionsService', () => { + let service: ConnectionsService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + service = new ConnectionsService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + it('should return connections with bound methods on each entity', async () => { + mockApiClient.get.mockResolvedValue([ + createMockConnection(), + createMockConnection({ id: IS_TEST_CONSTANTS.CONNECTION_ID_2 }), + ]); + + const result = await service.getAll({ + folderKey: IS_TEST_CONSTANTS.FOLDER_KEY, + pageSize: 50, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith(CONNECTION_ENDPOINTS.GET_ALL, { + headers: { [FOLDER_KEY]: IS_TEST_CONSTANTS.FOLDER_KEY }, + params: { pageSize: 50 }, + }); + expect(result).toHaveLength(2); + for (const conn of result) { + expect(typeof conn.ping).toBe('function'); + expect(typeof conn.reauthenticate).toBe('function'); + } + }); + + it('should default to empty array when API returns null', async () => { + mockApiClient.get.mockResolvedValue(null); + const result = await service.getAll(); + expect(result).toEqual([]); + }); + + it('should pass filter and mostRecentFirst as query params', async () => { + mockApiClient.get.mockResolvedValue([]); + await service.getAll({ filter: "name eq 'foo'", mostRecentFirst: true }); + expect(mockApiClient.get).toHaveBeenCalledWith(CONNECTION_ENDPOINTS.GET_ALL, { + headers: {}, + params: { filter: "name eq 'foo'", mostRecentFirst: true }, + }); + }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(IS_TEST_CONSTANTS.ERROR_CONNECTION_NOT_FOUND)); + await expect(service.getAll()).rejects.toThrow(IS_TEST_CONSTANTS.ERROR_CONNECTION_NOT_FOUND); + }); + }); + + describe('getById', () => { + it('should return a single connection with bound methods', async () => { + mockApiClient.get.mockResolvedValue(createMockConnection()); + + const result = await service.getById(IS_TEST_CONSTANTS.CONNECTION_ID); + + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTION_ENDPOINTS.GET_BY_ID(IS_TEST_CONSTANTS.CONNECTION_ID), + { headers: {}, params: {} }, + ); + expect(result.id).toBe(IS_TEST_CONSTANTS.CONNECTION_ID); + expect(typeof result.ping).toBe('function'); + expect(typeof result.reauthenticate).toBe('function'); + }); + + it('should forward includeConfigs as a query param', async () => { + mockApiClient.get.mockResolvedValue(createMockConnection()); + await service.getById(IS_TEST_CONSTANTS.CONNECTION_ID, { includeConfigs: true }); + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTION_ENDPOINTS.GET_BY_ID(IS_TEST_CONSTANTS.CONNECTION_ID), + { headers: {}, params: { includeConfigs: true } }, + ); + }); + + it('should send folder header when folderKey is provided', async () => { + mockApiClient.get.mockResolvedValue(createMockConnection()); + await service.getById(IS_TEST_CONSTANTS.CONNECTION_ID, { + folderKey: IS_TEST_CONSTANTS.FOLDER_KEY, + }); + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTION_ENDPOINTS.GET_BY_ID(IS_TEST_CONSTANTS.CONNECTION_ID), + { headers: { [FOLDER_KEY]: IS_TEST_CONSTANTS.FOLDER_KEY }, params: {} }, + ); + }); + + it('should throw ValidationError when connectionId is empty', async () => { + await expect(service.getById('')).rejects.toThrow(ValidationError); + }); + }); + + describe('ping', () => { + it('should return ping status', async () => { + mockApiClient.get.mockResolvedValue({ + connector: IS_TEST_CONSTANTS.CONNECTOR_KEY, + status: ConnectionState.Enabled, + }); + + const result = await service.ping(IS_TEST_CONSTANTS.CONNECTION_ID); + + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTION_ENDPOINTS.PING(IS_TEST_CONSTANTS.CONNECTION_ID), + { headers: {}, params: {} }, + ); + expect(result.status).toBe(ConnectionState.Enabled); + expect(result.connector).toBe(IS_TEST_CONSTANTS.CONNECTOR_KEY); + }); + + it('should forward forceRefresh as a query param', async () => { + mockApiClient.get.mockResolvedValue({ + connector: IS_TEST_CONSTANTS.CONNECTOR_KEY, + status: ConnectionState.Enabled, + }); + await service.ping(IS_TEST_CONSTANTS.CONNECTION_ID, { forceRefresh: true }); + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTION_ENDPOINTS.PING(IS_TEST_CONSTANTS.CONNECTION_ID), + { headers: {}, params: { forceRefresh: true } }, + ); + }); + + it('should throw ValidationError when connectionId is empty', async () => { + await expect(service.ping('')).rejects.toThrow(ValidationError); + }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(IS_TEST_CONSTANTS.ERROR_PING_FAILED)); + await expect(service.ping(IS_TEST_CONSTANTS.CONNECTION_ID)).rejects.toThrow( + IS_TEST_CONSTANTS.ERROR_PING_FAILED, + ); + }); + }); + + describe('reauthenticate', () => { + it('should start an OAuth session', async () => { + mockApiClient.post.mockResolvedValue({ + connector: IS_TEST_CONSTANTS.CONNECTOR_KEY, + sessionId: IS_TEST_CONSTANTS.AUTH_SESSION_ID, + expiresAt: IS_TEST_CONSTANTS.AUTH_EXPIRES_AT, + authUrl: IS_TEST_CONSTANTS.AUTH_URL, + }); + + const result = await service.reauthenticate(IS_TEST_CONSTANTS.CONNECTION_ID); + + expect(mockApiClient.post).toHaveBeenCalledWith( + CONNECTION_ENDPOINTS.REAUTHENTICATE(IS_TEST_CONSTANTS.CONNECTION_ID), + undefined, + { headers: {}, params: {} }, + ); + expect(result.sessionId).toBe(IS_TEST_CONSTANTS.AUTH_SESSION_ID); + expect(result.authUrl).toBe(IS_TEST_CONSTANTS.AUTH_URL); + }); + + it('should send folder header when folderKey is provided', async () => { + mockApiClient.post.mockResolvedValue({ + connector: IS_TEST_CONSTANTS.CONNECTOR_KEY, + sessionId: IS_TEST_CONSTANTS.AUTH_SESSION_ID, + expiresAt: IS_TEST_CONSTANTS.AUTH_EXPIRES_AT, + authUrl: IS_TEST_CONSTANTS.AUTH_URL, + }); + await service.reauthenticate(IS_TEST_CONSTANTS.CONNECTION_ID, { + folderKey: IS_TEST_CONSTANTS.FOLDER_KEY, + }); + expect(mockApiClient.post).toHaveBeenCalledWith( + CONNECTION_ENDPOINTS.REAUTHENTICATE(IS_TEST_CONSTANTS.CONNECTION_ID), + undefined, + { headers: { [FOLDER_KEY]: IS_TEST_CONSTANTS.FOLDER_KEY }, params: {} }, + ); + }); + + it('should throw ValidationError when connectionId is empty', async () => { + await expect(service.reauthenticate('')).rejects.toThrow(ValidationError); + }); + }); +}); diff --git a/tests/utils/constants/index.ts b/tests/utils/constants/index.ts index 6f88aebe8..e6c3c24aa 100644 --- a/tests/utils/constants/index.ts +++ b/tests/utils/constants/index.ts @@ -19,3 +19,4 @@ export * from './memory'; export * from './traces'; export * from './agents'; export * from './governance'; +export * from './integration-service'; diff --git a/tests/utils/constants/integration-service.ts b/tests/utils/constants/integration-service.ts new file mode 100644 index 000000000..e801dfbf5 --- /dev/null +++ b/tests/utils/constants/integration-service.ts @@ -0,0 +1,26 @@ +/** + * Integration Service test constants. + */ + +export const IS_TEST_CONSTANTS = { + CONNECTOR_KEY: 'uipath-uipath-airdk', + CONNECTOR_ID: 7074, + CONNECTOR_NAME: 'UiPath GenAI Activities', + CONNECTOR_TIER: '2', + CONNECTOR_LIFECYCLE_GA: 'GA', + CONNECTION_ID: '29d931e5-00a5-45e9-b5e9-b9bd80844396', + CONNECTION_ID_2: 'b4022e19-3007-4383-b664-30444b156286', + CONNECTION_NAME: 'UiPath GenAI Activities', + CONNECTION_OWNER: 'tester@uipath.com', + CONNECTION_CREATE_TIME: '2026-01-01T00:00:00.000Z', + CONNECTION_UPDATE_TIME: '2026-06-01T00:00:00.000Z', + ELEMENT_INSTANCE_ID: 12345, + FOLDER_KEY: '5638e7df-c0ca-400d-af47-ea001baf6fd5', + FOLDER_DISPLAY_NAME: 'Test Folder', + AUTH_SESSION_ID: 'sess_abcdef0123456789', + AUTH_URL: 'https://alpha.uipath.com/oauth/authorize?session_id=sess_abcdef0123456789', + AUTH_EXPIRES_AT: 1782303998000, + ERROR_CONNECTOR_NOT_FOUND: 'Connector not found', + ERROR_CONNECTION_NOT_FOUND: 'Connection not found', + ERROR_PING_FAILED: 'Ping failed', +} as const; diff --git a/tests/utils/mocks/index.ts b/tests/utils/mocks/index.ts index 49e54fd4d..3b0b09740 100644 --- a/tests/utils/mocks/index.ts +++ b/tests/utils/mocks/index.ts @@ -22,6 +22,7 @@ export * from './attachments'; export * from './memory'; export * from './traces'; export * from './governance'; +export * from './integration-service'; // Re-export constants for convenience export * from '../constants'; \ No newline at end of file diff --git a/tests/utils/mocks/integration-service.ts b/tests/utils/mocks/integration-service.ts new file mode 100644 index 000000000..61ea37089 --- /dev/null +++ b/tests/utils/mocks/integration-service.ts @@ -0,0 +1,51 @@ +/** + * Integration Service mock factories. + * + * Returns raw response shapes (no bound methods) — the service layer attaches + * methods via `create{Entity}WithMethods()`. + */ + +import { IS_TEST_CONSTANTS } from '../constants/integration-service'; +import { createMockBaseResponse } from './core'; +import { ConnectionState } from '../../../src/models/integration-service/connections.types'; +import type { RawConnectionGetResponse } from '../../../src/models/integration-service/connections.types'; + +/** + * Creates a mock raw Connection response. + */ +export const createMockConnection = ( + overrides: Partial = {}, +): RawConnectionGetResponse => { + return createMockBaseResponse( + { + id: IS_TEST_CONSTANTS.CONNECTION_ID, + name: IS_TEST_CONSTANTS.CONNECTION_NAME, + owner: IS_TEST_CONSTANTS.CONNECTION_OWNER, + createTime: IS_TEST_CONSTANTS.CONNECTION_CREATE_TIME, + updateTime: IS_TEST_CONSTANTS.CONNECTION_UPDATE_TIME, + state: ConnectionState.Enabled, + apiBaseUri: 'https://api.example.invalid/v1', + elementInstanceId: IS_TEST_CONSTANTS.ELEMENT_INSTANCE_ID, + connector: { + id: IS_TEST_CONSTANTS.CONNECTOR_ID, + key: IS_TEST_CONSTANTS.CONNECTOR_KEY, + name: IS_TEST_CONSTANTS.CONNECTOR_NAME, + lifeCycleStage: IS_TEST_CONSTANTS.CONNECTOR_LIFECYCLE_GA, + tier: IS_TEST_CONSTANTS.CONNECTOR_TIER, + hasEvents: true, + isPrivate: false, + }, + isDefault: true, + lastUsedTime: IS_TEST_CONSTANTS.CONNECTION_UPDATE_TIME, + connectionIdentity: 'tester@uipath.com', + pollingIntervalInMinutes: 5, + folder: { + key: IS_TEST_CONSTANTS.FOLDER_KEY, + displayName: IS_TEST_CONSTANTS.FOLDER_DISPLAY_NAME, + }, + elementVersion: '1.0.0', + byoaConnection: false, + }, + overrides, + ); +}; From 1b7378fd1eff950c9b1cf7cfc3810fc13da7e495 Mon Sep 17 00:00:00 2001 From: maninder-uipath Date: Mon, 29 Jun 2026 14:02:51 +0530 Subject: [PATCH 2/5] feat(integration-service): add Connectors service [JAR-IS-2] Adds the Integration Service Connectors service (getAll, getById, getDefaultConnection, getConnections) exposed via the @uipath/uipath-typescript/is-connectors subpath. Connectors delegates connection lookups to the Connections service. Includes unit tests, endpoint constants, OAuth scope docs, and mkdocs nav. Co-Authored-By: Claude Opus 4.8 --- docs/oauth-scopes.md | 9 + mkdocs.yml | 1 + package.json | 10 + rollup.config.js | 5 + .../integration-service/connectors.models.ts | 156 +++++++++++++ .../integration-service/connectors.types.ts | 116 ++++++++++ src/models/integration-service/index.ts | 2 + .../connectors/connectors.ts | 215 ++++++++++++++++++ .../integration-service/connectors/index.ts | 26 +++ .../endpoints/integration-service.ts | 7 + .../integration-service/connectors.test.ts | 183 +++++++++++++++ tests/utils/mocks/integration-service.ts | 33 +++ 12 files changed, 763 insertions(+) create mode 100644 src/models/integration-service/connectors.models.ts create mode 100644 src/models/integration-service/connectors.types.ts create mode 100644 src/services/integration-service/connectors/connectors.ts create mode 100644 src/services/integration-service/connectors/index.ts create mode 100644 tests/unit/services/integration-service/connectors.test.ts diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index b0d0a70c0..02199bced 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -2,6 +2,15 @@ This page lists the specific OAuth scopes required in external app for each SDK method. +## Integration Service — Connectors + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `ConnectionService` or `ConnectionServiceUser` | +| `getById()` | `ConnectionService` or `ConnectionServiceUser` | +| `getDefaultConnection()` | `ConnectionService` or `ConnectionServiceUser` | +| `getConnections()` | `ConnectionService` or `ConnectionServiceUser` | + ## Integration Service — Connections | Method | OAuth Scope | diff --git a/mkdocs.yml b/mkdocs.yml index 6798c6e56..a00358b19 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -200,6 +200,7 @@ nav: - Choice Sets: api/interfaces/ChoiceSetServiceModel.md - Governance: api/interfaces/GovernanceServiceModel.md - Integration Service: + - Connectors: api/interfaces/ConnectorsServiceModel.md - Connections: api/interfaces/ConnectionsServiceModel.md - Maestro: - Processes: api/interfaces/MaestroProcessesServiceModel.md diff --git a/package.json b/package.json index e6208783e..24069cbd6 100644 --- a/package.json +++ b/package.json @@ -216,6 +216,16 @@ "default": "./dist/governance/index.cjs" } }, + "./is-connectors": { + "import": { + "types": "./dist/is-connectors/index.d.ts", + "default": "./dist/is-connectors/index.mjs" + }, + "require": { + "types": "./dist/is-connectors/index.d.ts", + "default": "./dist/is-connectors/index.cjs" + } + }, "./is-connections": { "import": { "types": "./dist/is-connections/index.d.ts", diff --git a/rollup.config.js b/rollup.config.js index 9e60edff5..7af530ad0 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -229,6 +229,11 @@ const serviceEntries = [ input: 'src/services/governance/index.ts', output: 'governance/index' }, + { + name: 'is-connectors', + input: 'src/services/integration-service/connectors/index.ts', + output: 'is-connectors/index' + }, { name: 'is-connections', input: 'src/services/integration-service/connections/index.ts', diff --git a/src/models/integration-service/connectors.models.ts b/src/models/integration-service/connectors.models.ts new file mode 100644 index 000000000..3f4fabc89 --- /dev/null +++ b/src/models/integration-service/connectors.models.ts @@ -0,0 +1,156 @@ +/** + * Integration Service — Connector models + * + * Connectors are read-only catalog entries — no bound entity methods are + * attached (no `update`, `cancel`, etc. operate on a connector). + */ + +import { + RawConnectorGetResponse, + ConnectorGetAllOptions, + ConnectorGetDefaultConnectionOptions, + ConnectorGetConnectionsOptions, +} from './connectors.types'; +import { ConnectionGetResponse } from './connections.models'; + +/** + * A connector catalog entry from the Integration Service. + */ +export type ConnectorGetResponse = RawConnectorGetResponse; + +/** + * Service for inspecting UiPath Integration Service connectors. + * + * Connectors are the catalog of integrations available on a tenant (Slack, + * Microsoft 365, Salesforce, custom connectors, ...). Use this service to + * discover connectors, fetch their metadata, and list / locate the connection + * instances built on top of them. + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * const allConnectors = await connectors.getAll(); + * ``` + */ +export interface ConnectorsServiceModel { + /** + * List all connectors available on this tenant. + * + * Returns a plain array — the Integration Service does not paginate this endpoint. + * + * @param options - Optional filter (e.g. limit to connectors exposing HTTP Request activities) + * @returns Promise resolving to an array of {@link ConnectorGetResponse} + * @example + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * + * const all = await connectors.getAll(); + * for (const connector of all) { + * console.log(`${connector.key} — ${connector.name} (${connector.lifeCycleStage})`); + * } + * ``` + * + * @example + * ```typescript + * // Only connectors that expose an HTTP Request activity + * const httpEnabled = await connectors.getAll({ hasHttpRequest: true }); + * ``` + */ + getAll(options?: ConnectorGetAllOptions): Promise; + + /** + * Get a single connector by key or numeric ID. + * + * @param keyOrId - Connector key (e.g. `uipath-slack`) or numeric ID as a string + * @returns Promise resolving to a {@link ConnectorGetResponse} + * @example + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * + * const slack = await connectors.getById('uipath-slack'); + * console.log(slack.name, slack.authentication?.type); + * ``` + */ + getById(keyOrId: string): Promise; + + /** + * Get the default connection for a connector in the current folder. + * + * Each connector may have a single connection marked as default per folder; + * use this to resolve "which connection should I use for Slack?" without + * paging through the full list. + * + * @param keyOrId - Connector key or numeric ID as a string + * @param options - Folder scoping options + * @returns Promise resolving to a {@link ConnectionGetResponse} + * @example + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * + * const defaultSlack = await connectors.getDefaultConnection('uipath-slack', { + * folderKey: '', + * }); + * + * // Use bound methods directly on the entity + * const status = await defaultSlack.ping(); + * console.log(status.status); + * ``` + */ + getDefaultConnection( + keyOrId: string, + options?: ConnectorGetDefaultConnectionOptions, + ): Promise; + + /** + * List all connections for a connector. + * + * Returns a plain array — the Integration Service uses page-indexed + * pagination (no continuation cursor). Increment `pageIndex` to walk pages. + * + * @param keyOrId - Connector key or numeric ID as a string + * @param options - Folder scoping, paging, and sorting options + * @returns Promise resolving to an array of {@link ConnectionGetResponse} + * @example + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * + * // First page of Slack connections in a folder + * const slackConnections = await connectors.getConnections('uipath-slack', { + * folderKey: '', + * pageSize: 25, + * }); + * + * for (const conn of slackConnections) { + * const status = await conn.ping(); + * console.log(`${conn.name}: ${status.status}`); + * } + * ``` + * + * @example + * ```typescript + * // Walk subsequent pages + * const page2 = await connectors.getConnections('uipath-slack', { + * folderKey: '', + * pageSize: 25, + * pageIndex: 2, + * }); + * ``` + */ + getConnections( + keyOrId: string, + options?: ConnectorGetConnectionsOptions, + ): Promise; +} diff --git a/src/models/integration-service/connectors.types.ts b/src/models/integration-service/connectors.types.ts new file mode 100644 index 000000000..7d3556d52 --- /dev/null +++ b/src/models/integration-service/connectors.types.ts @@ -0,0 +1,116 @@ +/** + * Integration Service — Connector types + * + * Types for the Connectors API (`connections_/api/v1/Connectors`). + */ + +/** + * Authentication scheme descriptor on a connector (`oauth2`, `basic`, `apiKey`, ...). + * Shape is connector-dependent; exposed as a record so the SDK doesn't impose + * a closed schema on third-party connectors. + */ +export interface ConnectorAuthentication { + /** Auth type identifier (e.g. `oauth2`, `basic`, `apiKey`). */ + type?: string; + /** Free-form fields specific to the auth scheme. */ + [key: string]: unknown; +} + +/** + * Template metadata describing how the connector should be configured at runtime. + * Shape is connector-dependent; preserved as-is from the API. + */ +export interface ConnectorTemplateProperties { + [key: string]: unknown; +} + +/** + * Raw connector response from the Integration Service API. + */ +export interface RawConnectorGetResponse { + /** Numeric connector ID. */ + id: number; + /** Stable connector key (used as `elementKey` for Elements API calls). */ + key: string; + /** Display name (e.g. "Slack", "Microsoft OneDrive"). */ + name: string; + /** Free-form description. */ + description?: string; + /** URL to the connector's logo image. */ + image?: string; + /** Whether the connector is private (custom or org-scoped). */ + isPrivate: boolean; + /** Whether the connector publishes events / triggers. */ + hasEvents?: boolean; + /** Event types the connector emits. */ + eventTypes?: string[]; + /** Lifecycle stage (e.g. `GA`, `BETA`, `CUSTOM`). */ + lifeCycleStage?: string; + /** Tag string (comma-separated). */ + tags?: string; + /** Connector classification (e.g. `application`, `database`). */ + type?: string; + /** Tier (e.g. `1`, `2`). */ + tier?: string; + /** Boolean feature flags exposed by the connector. */ + flags?: Record; + /** Template properties describing how to configure the connector. */ + templateProperties?: ConnectorTemplateProperties; + /** Authentication scheme(s) supported by the connector. */ + authentication?: ConnectorAuthentication; + /** Categories the connector belongs to (e.g. `CRM`, `Communication`). */ + categories?: string[]; + /** Number of connection instances for this connector. */ + connectionCount?: number; + /** ID of the parent custom connector (when this connector is a fork). */ + customConnectorId?: number; + /** Whether the connector is enabled on this tenant. */ + enabled?: boolean; + /** Whether the caller owns the connector. */ + isOwner?: boolean; + /** Vendor / publisher name. */ + vendorName?: string; +} + +/** + * Options for {@link ConnectorsServiceModel.getAll}. + */ +export interface ConnectorGetAllOptions { + /** Limit results to connectors that expose an HTTP Request activity. */ + hasHttpRequest?: boolean; +} + +// getById takes no body/query options — keyOrId is positional. + +/** + * Options for {@link ConnectorsServiceModel.getDefaultConnection}. + */ +export interface ConnectorGetDefaultConnectionOptions { + /** Folder key (GUID) to scope the lookup. */ + folderKey?: string; + /** Search across folders the caller has access to. */ + allFolders?: boolean; +} + +/** + * Options for {@link ConnectorsServiceModel.getConnections}. + * + * The API returns a plain array — pagination is page-indexed via `pageIndex`/`pageSize`. + * There is no continuation cursor; callers paginate by incrementing `pageIndex`. + */ +export interface ConnectorGetConnectionsOptions { + /** Folder key (GUID) to scope the query to a specific folder. */ + folderKey?: string; + /** Include connections from all folders the caller has access to. */ + allFolders?: boolean; + /** Include only default connections. */ + folderDefaults?: boolean; + /** 1-indexed page number. */ + pageIndex?: number; + /** Page size (number of items per page). */ + pageSize?: number; + /** Sort newest-first. */ + mostRecentFirst?: boolean; + /** Force a connector refresh, skipping cache. */ + fetchLatest?: boolean; +} diff --git a/src/models/integration-service/index.ts b/src/models/integration-service/index.ts index 9eb215d37..a18531f9c 100644 --- a/src/models/integration-service/index.ts +++ b/src/models/integration-service/index.ts @@ -4,5 +4,7 @@ * Internal-types files are intentionally not re-exported. */ +export * from './connectors.types'; +export * from './connectors.models'; export * from './connections.types'; export * from './connections.models'; diff --git a/src/services/integration-service/connectors/connectors.ts b/src/services/integration-service/connectors/connectors.ts new file mode 100644 index 000000000..997f3d9ad --- /dev/null +++ b/src/services/integration-service/connectors/connectors.ts @@ -0,0 +1,215 @@ +import { BaseService } from '../../base'; +import { track } from '../../../core/telemetry'; +import { ValidationError } from '../../../core/errors'; +import { createHeaders } from '../../../utils/http/headers'; +import { FOLDER_KEY } from '../../../utils/constants/headers'; +import { CONNECTOR_ENDPOINTS } from '../../../utils/constants/endpoints'; +import { QueryParams } from '../../../models/common/request-spec'; +import { + RawConnectorGetResponse, + ConnectorGetAllOptions, + ConnectorGetDefaultConnectionOptions, + ConnectorGetConnectionsOptions, +} from '../../../models/integration-service/connectors.types'; +import { + ConnectorGetResponse, + ConnectorsServiceModel, +} from '../../../models/integration-service/connectors.models'; +import { + ConnectionGetResponse, + ConnectionsServiceModel, + createConnectionWithMethods, +} from '../../../models/integration-service/connections.models'; +import { RawConnectionGetResponse } from '../../../models/integration-service/connections.types'; +import { ConnectionsService } from '../connections/connections'; +import type { IUiPath } from '../../../core/types'; + +/** + * Service for inspecting UiPath Integration Service connectors. + * + * Connectors are the catalog of integrations available on a tenant (Slack, + * Microsoft 365, Salesforce, custom connectors, ...). Use this service to + * discover connectors, fetch their metadata, and list / locate the connection + * instances built on top of them. + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * const allConnectors = await connectors.getAll(); + * ``` + */ +export class ConnectorsService extends BaseService implements ConnectorsServiceModel { + private connectionsService: ConnectionsServiceModel; + + /** + * Creates an instance of the Connectors service. + * + * @param instance - UiPath SDK instance providing authentication and configuration + */ + constructor(instance: IUiPath) { + super(instance); + this.connectionsService = new ConnectionsService(instance); + } + + /** + * List all connectors available on this tenant. + * + * Returns a plain array — the Integration Service does not paginate this endpoint. + * + * @param options - Optional filter (e.g. limit to connectors exposing HTTP Request activities) + * @returns Promise resolving to an array of {@link ConnectorGetResponse} + * @example + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * + * const all = await connectors.getAll(); + * for (const connector of all) { + * console.log(`${connector.key} — ${connector.name} (${connector.lifeCycleStage})`); + * } + * ``` + * + * @example + * ```typescript + * // Only connectors that expose an HTTP Request activity + * const httpEnabled = await connectors.getAll({ hasHttpRequest: true }); + * ``` + */ + @track('Connectors.GetAll') + async getAll(options?: ConnectorGetAllOptions): Promise { + const response = await this.get(CONNECTOR_ENDPOINTS.GET_ALL, { + params: options as QueryParams | undefined, + }); + return response.data ?? []; + } + + /** + * Get a single connector by key or numeric ID. + * + * @param keyOrId - Connector key (e.g. `uipath-slack`) or numeric ID as a string + * @returns Promise resolving to a {@link ConnectorGetResponse} + * @example + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * + * const slack = await connectors.getById('uipath-slack'); + * console.log(slack.name, slack.authentication?.type); + * ``` + */ + @track('Connectors.GetById') + async getById(keyOrId: string): Promise { + if (!keyOrId) { + throw new ValidationError({ message: 'keyOrId is required for getById' }); + } + const response = await this.get(CONNECTOR_ENDPOINTS.GET_BY_ID(keyOrId)); + return response.data; + } + + /** + * Get the default connection for a connector in the current folder. + * + * Each connector may have a single connection marked as default per folder; + * use this to resolve "which connection should I use for Slack?" without + * paging through the full list. + * + * @param keyOrId - Connector key or numeric ID as a string + * @param options - Folder scoping options + * @returns Promise resolving to a {@link ConnectionGetResponse} + * @example + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * + * const defaultSlack = await connectors.getDefaultConnection('uipath-slack', { + * folderKey: '', + * }); + * + * // Use bound methods directly on the entity + * const status = await defaultSlack.ping(); + * console.log(status.status); + * ``` + */ + @track('Connectors.GetDefaultConnection') + async getDefaultConnection( + keyOrId: string, + options?: ConnectorGetDefaultConnectionOptions, + ): Promise { + if (!keyOrId) { + throw new ValidationError({ message: 'keyOrId is required for getDefaultConnection' }); + } + const { folderKey, ...queryOptions } = options ?? {}; + const response = await this.get( + CONNECTOR_ENDPOINTS.GET_DEFAULT_CONNECTION(keyOrId), + { + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + params: queryOptions as QueryParams, + }, + ); + return createConnectionWithMethods(response.data, this.connectionsService); + } + + /** + * List all connections for a connector. + * + * Returns a plain array — the Integration Service uses page-indexed + * pagination (no continuation cursor). Increment `pageIndex` to walk pages. + * + * @param keyOrId - Connector key or numeric ID as a string + * @param options - Folder scoping, paging, and sorting options + * @returns Promise resolving to an array of {@link ConnectionGetResponse} + * @example + * ```typescript + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const connectors = new Connectors(sdk); + * + * // First page of Slack connections in a folder + * const slackConnections = await connectors.getConnections('uipath-slack', { + * folderKey: '', + * pageSize: 25, + * }); + * + * for (const conn of slackConnections) { + * const status = await conn.ping(); + * console.log(`${conn.name}: ${status.status}`); + * } + * ``` + * + * @example + * ```typescript + * // Walk subsequent pages + * const page2 = await connectors.getConnections('uipath-slack', { + * folderKey: '', + * pageSize: 25, + * pageIndex: 2, + * }); + * ``` + */ + @track('Connectors.GetConnections') + async getConnections( + keyOrId: string, + options?: ConnectorGetConnectionsOptions, + ): Promise { + if (!keyOrId) { + throw new ValidationError({ message: 'keyOrId is required for getConnections' }); + } + const { folderKey, ...queryOptions } = options ?? {}; + const response = await this.get( + CONNECTOR_ENDPOINTS.GET_CONNECTIONS(keyOrId), + { + headers: createHeaders({ [FOLDER_KEY]: folderKey }), + params: queryOptions as QueryParams, + }, + ); + return (response.data ?? []).map((conn) => createConnectionWithMethods(conn, this.connectionsService)); + } +} diff --git a/src/services/integration-service/connectors/index.ts b/src/services/integration-service/connectors/index.ts new file mode 100644 index 000000000..4c59af4cf --- /dev/null +++ b/src/services/integration-service/connectors/index.ts @@ -0,0 +1,26 @@ +/** + * Integration Service — Connectors module. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Connectors } from '@uipath/uipath-typescript/is-connectors'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const connectors = new Connectors(sdk); + * const all = await connectors.getAll(); + * ``` + * + * @module + */ + +export { ConnectorsService as Connectors, ConnectorsService } from './connectors'; + +export * from '../../../models/integration-service/connectors.types'; +export * from '../../../models/integration-service/connectors.models'; + +// Re-exported because Connector methods return Connection entities. +export * from '../../../models/integration-service/connections.types'; +export * from '../../../models/integration-service/connections.models'; diff --git a/src/utils/constants/endpoints/integration-service.ts b/src/utils/constants/endpoints/integration-service.ts index 0d95e6040..f3596b9c2 100644 --- a/src/utils/constants/endpoints/integration-service.ts +++ b/src/utils/constants/endpoints/integration-service.ts @@ -6,6 +6,13 @@ import { CONNECTIONS_BASE } from './base'; +export const CONNECTOR_ENDPOINTS = { + GET_ALL: `${CONNECTIONS_BASE}/api/v1/Connectors`, + GET_BY_ID: (keyOrId: string) => `${CONNECTIONS_BASE}/api/v1/Connectors/${encodeURIComponent(keyOrId)}`, + GET_DEFAULT_CONNECTION: (keyOrId: string) => `${CONNECTIONS_BASE}/api/v1/Connectors/${encodeURIComponent(keyOrId)}/connection`, + GET_CONNECTIONS: (keyOrId: string) => `${CONNECTIONS_BASE}/api/v1/Connectors/${encodeURIComponent(keyOrId)}/connections`, +} as const; + export const CONNECTION_ENDPOINTS = { GET_ALL: `${CONNECTIONS_BASE}/api/v1/Connections`, GET_BY_ID: (connectionId: string) => `${CONNECTIONS_BASE}/api/v1/Connections/${encodeURIComponent(connectionId)}`, diff --git a/tests/unit/services/integration-service/connectors.test.ts b/tests/unit/services/integration-service/connectors.test.ts new file mode 100644 index 000000000..b8793a677 --- /dev/null +++ b/tests/unit/services/integration-service/connectors.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ConnectorsService } from '../../../../src/services/integration-service/connectors/connectors'; +import { CONNECTOR_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { ValidationError } from '../../../../src/core/errors'; +import { FOLDER_KEY } from '../../../../src/utils/constants/headers'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { + IS_TEST_CONSTANTS, + createMockConnector, + createMockConnection, + createMockError, + TEST_CONSTANTS, +} from '../../../utils/mocks'; + +vi.mock('../../../../src/core/http/api-client'); + +describe('ConnectorsService', () => { + let service: ConnectorsService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + service = new ConnectorsService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + it('should return all connectors as a plain array', async () => { + const mock = [createMockConnector(), createMockConnector({ key: 'uipath-slack', name: 'Slack' })]; + mockApiClient.get.mockResolvedValue(mock); + + const result = await service.getAll(); + + expect(mockApiClient.get).toHaveBeenCalledWith(CONNECTOR_ENDPOINTS.GET_ALL, { + params: undefined, + }); + expect(result).toHaveLength(2); + expect(result[0].key).toBe(IS_TEST_CONSTANTS.CONNECTOR_KEY); + expect(result[1].key).toBe('uipath-slack'); + }); + + it('should forward hasHttpRequest option as a query param', async () => { + mockApiClient.get.mockResolvedValue([]); + + await service.getAll({ hasHttpRequest: true }); + + expect(mockApiClient.get).toHaveBeenCalledWith(CONNECTOR_ENDPOINTS.GET_ALL, { + params: { hasHttpRequest: true }, + }); + }); + + it('should handle an empty response', async () => { + mockApiClient.get.mockResolvedValue([]); + const result = await service.getAll(); + expect(result).toEqual([]); + }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + await expect(service.getAll()).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('getById', () => { + it('should return a single connector by key', async () => { + const mock = createMockConnector(); + mockApiClient.get.mockResolvedValue(mock); + + const result = await service.getById(IS_TEST_CONSTANTS.CONNECTOR_KEY); + + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTOR_ENDPOINTS.GET_BY_ID(IS_TEST_CONSTANTS.CONNECTOR_KEY), + {}, + ); + expect(result.id).toBe(IS_TEST_CONSTANTS.CONNECTOR_ID); + }); + + it('should throw ValidationError when keyOrId is empty', async () => { + await expect(service.getById('')).rejects.toThrow(ValidationError); + expect(mockApiClient.get).not.toHaveBeenCalled(); + }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(IS_TEST_CONSTANTS.ERROR_CONNECTOR_NOT_FOUND)); + await expect(service.getById(IS_TEST_CONSTANTS.CONNECTOR_KEY)).rejects.toThrow( + IS_TEST_CONSTANTS.ERROR_CONNECTOR_NOT_FOUND, + ); + }); + }); + + describe('getDefaultConnection', () => { + it('should return the default connection with bound methods', async () => { + const mock = createMockConnection(); + mockApiClient.get.mockResolvedValue(mock); + + const result = await service.getDefaultConnection(IS_TEST_CONSTANTS.CONNECTOR_KEY, { + folderKey: IS_TEST_CONSTANTS.FOLDER_KEY, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTOR_ENDPOINTS.GET_DEFAULT_CONNECTION(IS_TEST_CONSTANTS.CONNECTOR_KEY), + { + headers: { [FOLDER_KEY]: IS_TEST_CONSTANTS.FOLDER_KEY }, + params: {}, + }, + ); + expect(result.id).toBe(IS_TEST_CONSTANTS.CONNECTION_ID); + // Bound methods attached: + expect(typeof result.ping).toBe('function'); + expect(typeof result.reauthenticate).toBe('function'); + }); + + it('should send no folder header when folderKey is omitted', async () => { + mockApiClient.get.mockResolvedValue(createMockConnection()); + + await service.getDefaultConnection(IS_TEST_CONSTANTS.CONNECTOR_KEY); + + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTOR_ENDPOINTS.GET_DEFAULT_CONNECTION(IS_TEST_CONSTANTS.CONNECTOR_KEY), + { headers: {}, params: {} }, + ); + }); + + it('should pass allFolders as a query param', async () => { + mockApiClient.get.mockResolvedValue(createMockConnection()); + + await service.getDefaultConnection(IS_TEST_CONSTANTS.CONNECTOR_KEY, { allFolders: true }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTOR_ENDPOINTS.GET_DEFAULT_CONNECTION(IS_TEST_CONSTANTS.CONNECTOR_KEY), + { headers: {}, params: { allFolders: true } }, + ); + }); + + it('should throw ValidationError when keyOrId is empty', async () => { + await expect(service.getDefaultConnection('')).rejects.toThrow(ValidationError); + }); + }); + + describe('getConnections', () => { + it('should return connections with bound methods on each entity', async () => { + mockApiClient.get.mockResolvedValue([ + createMockConnection(), + createMockConnection({ id: IS_TEST_CONSTANTS.CONNECTION_ID_2, name: 'second' }), + ]); + + const result = await service.getConnections(IS_TEST_CONSTANTS.CONNECTOR_KEY, { + folderKey: IS_TEST_CONSTANTS.FOLDER_KEY, + pageSize: 25, + pageIndex: 1, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + CONNECTOR_ENDPOINTS.GET_CONNECTIONS(IS_TEST_CONSTANTS.CONNECTOR_KEY), + { + headers: { [FOLDER_KEY]: IS_TEST_CONSTANTS.FOLDER_KEY }, + params: { pageSize: 25, pageIndex: 1 }, + }, + ); + expect(result).toHaveLength(2); + for (const conn of result) { + expect(typeof conn.ping).toBe('function'); + expect(typeof conn.reauthenticate).toBe('function'); + } + }); + + it('should default to empty array when API returns null', async () => { + mockApiClient.get.mockResolvedValue(null); + const result = await service.getConnections(IS_TEST_CONSTANTS.CONNECTOR_KEY); + expect(result).toEqual([]); + }); + + it('should throw ValidationError when keyOrId is empty', async () => { + await expect(service.getConnections('')).rejects.toThrow(ValidationError); + }); + }); +}); diff --git a/tests/utils/mocks/integration-service.ts b/tests/utils/mocks/integration-service.ts index 61ea37089..90a093b0e 100644 --- a/tests/utils/mocks/integration-service.ts +++ b/tests/utils/mocks/integration-service.ts @@ -8,8 +8,41 @@ import { IS_TEST_CONSTANTS } from '../constants/integration-service'; import { createMockBaseResponse } from './core'; import { ConnectionState } from '../../../src/models/integration-service/connections.types'; +import type { RawConnectorGetResponse } from '../../../src/models/integration-service/connectors.types'; import type { RawConnectionGetResponse } from '../../../src/models/integration-service/connections.types'; +/** + * Creates a mock raw Connector response. + */ +export const createMockConnector = ( + overrides: Partial = {}, +): RawConnectorGetResponse => { + return createMockBaseResponse( + { + id: IS_TEST_CONSTANTS.CONNECTOR_ID, + key: IS_TEST_CONSTANTS.CONNECTOR_KEY, + name: IS_TEST_CONSTANTS.CONNECTOR_NAME, + description: 'Mock connector for tests', + image: 'https://example.invalid/icon.png', + isPrivate: false, + hasEvents: true, + eventTypes: ['INDEX_COMPLETED'], + lifeCycleStage: IS_TEST_CONSTANTS.CONNECTOR_LIFECYCLE_GA, + tags: 'genai', + type: 'application', + tier: IS_TEST_CONSTANTS.CONNECTOR_TIER, + flags: {}, + authentication: { type: 'oauth2' }, + categories: ['AI'], + connectionCount: 1, + enabled: true, + isOwner: false, + vendorName: 'UiPath', + }, + overrides, + ); +}; + /** * Creates a mock raw Connection response. */ From 73e957ffede2c4dab0583ea99cd5470fd25e4a83 Mon Sep 17 00:00:00 2001 From: Maninder Singh <91181689+maninder-uipath@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:37:31 +0530 Subject: [PATCH 3/5] feat(integration-service): add Elements service (#556) * feat(integration-service): add Elements service [JAR-IS-3] Adds the Integration Service Elements service (objects, activities, object metadata, and event-object reads at both connector and connection- instance scope) exposed via the @uipath/uipath-typescript/is-elements subpath. Includes ELEMENT_ENDPOINTS constants, unit tests, OAuth scope docs, and mkdocs nav. Co-Authored-By: Claude Opus 4.8 * feat(integration-service): add Execution passthrough [JAR-IS-4] (#557) Adds the Integration Service Execution passthrough (execute) exposed via the @uipath/uipath-typescript/is-execution subpath. Executes arbitrary HTTP operations against a connection instance and returns the full response envelope without throwing on non-2xx. Includes unit tests and OAuth scope docs. Reuses ELEMENT_ENDPOINTS.INSTANCE.EXECUTE. Co-authored-by: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- docs/oauth-scopes.md | 20 ++ mkdocs.yml | 1 + package.json | 20 ++ rollup.config.js | 10 + .../integration-service/elements.models.ts | 241 +++++++++++++ .../integration-service/elements.types.ts | 296 ++++++++++++++++ .../integration-service/execution.types.ts | 42 +++ src/models/integration-service/index.ts | 2 + .../integration-service/elements/elements.ts | 329 ++++++++++++++++++ .../integration-service/elements/index.ts | 22 ++ .../execution/execution.ts | 154 ++++++++ .../integration-service/execution/index.ts | 19 + src/utils/constants/endpoints/base.ts | 1 + .../endpoints/integration-service.ts | 49 ++- .../integration-service/elements.test.ts | 189 ++++++++++ .../integration-service/execution.test.ts | 148 ++++++++ 16 files changed, 1541 insertions(+), 2 deletions(-) create mode 100644 src/models/integration-service/elements.models.ts create mode 100644 src/models/integration-service/elements.types.ts create mode 100644 src/models/integration-service/execution.types.ts create mode 100644 src/services/integration-service/elements/elements.ts create mode 100644 src/services/integration-service/elements/index.ts create mode 100644 src/services/integration-service/execution/execution.ts create mode 100644 src/services/integration-service/execution/index.ts create mode 100644 tests/unit/services/integration-service/elements.test.ts create mode 100644 tests/unit/services/integration-service/execution.test.ts diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 02199bced..c97f9a4d3 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -20,6 +20,26 @@ This page lists the specific OAuth scopes required in external app for each SDK | `ping()` | `ConnectionService` or `ConnectionServiceUser` | | `reauthenticate()` | `ConnectionService` | +## Integration Service — Elements + +| Method | OAuth Scope | +|--------|-------------| +| `getObjects()` | `ConnectionService` or `ConnectionServiceUser` | +| `getActivities()` | `ConnectionService` or `ConnectionServiceUser` | +| `getObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | +| `getEventObjects()` | `ConnectionService` or `ConnectionServiceUser` | +| `getEventObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | +| `getInstanceObjects()` | `ConnectionService` or `ConnectionServiceUser` | +| `getInstanceObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | +| `getInstanceEventObjects()` | `ConnectionService` or `ConnectionServiceUser` | +| `getInstanceEventObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | + +## Integration Service — Execution + +| Function | OAuth Scope | +|----------|-------------| +| `execute()` | `ConnectionService` or `ConnectionServiceUser` (plus any third-party scopes required by the underlying connection) | + ## Assets | Method | OAuth Scope | diff --git a/mkdocs.yml b/mkdocs.yml index a00358b19..85a9e08dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -202,6 +202,7 @@ nav: - Integration Service: - Connectors: api/interfaces/ConnectorsServiceModel.md - Connections: api/interfaces/ConnectionsServiceModel.md + - Elements: api/interfaces/ElementsServiceModel.md - Maestro: - Processes: api/interfaces/MaestroProcessesServiceModel.md - Process Instances: api/interfaces/ProcessInstancesServiceModel.md diff --git a/package.json b/package.json index 24069cbd6..469815da2 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,16 @@ "default": "./dist/is-connectors/index.cjs" } }, + "./is-elements": { + "import": { + "types": "./dist/is-elements/index.d.ts", + "default": "./dist/is-elements/index.mjs" + }, + "require": { + "types": "./dist/is-elements/index.d.ts", + "default": "./dist/is-elements/index.cjs" + } + }, "./is-connections": { "import": { "types": "./dist/is-connections/index.d.ts", @@ -235,6 +245,16 @@ "types": "./dist/is-connections/index.d.ts", "default": "./dist/is-connections/index.cjs" } + }, + "./is-execution": { + "import": { + "types": "./dist/is-execution/index.d.ts", + "default": "./dist/is-execution/index.mjs" + }, + "require": { + "types": "./dist/is-execution/index.d.ts", + "default": "./dist/is-execution/index.cjs" + } } }, "files": [ diff --git a/rollup.config.js b/rollup.config.js index 7af530ad0..4ee3bdb83 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -234,10 +234,20 @@ const serviceEntries = [ input: 'src/services/integration-service/connectors/index.ts', output: 'is-connectors/index' }, + { + name: 'is-elements', + input: 'src/services/integration-service/elements/index.ts', + output: 'is-elements/index' + }, { name: 'is-connections', input: 'src/services/integration-service/connections/index.ts', output: 'is-connections/index' + }, + { + name: 'is-execution', + input: 'src/services/integration-service/execution/index.ts', + output: 'is-execution/index' } ]; diff --git a/src/models/integration-service/elements.models.ts b/src/models/integration-service/elements.models.ts new file mode 100644 index 000000000..f62fabbdd --- /dev/null +++ b/src/models/integration-service/elements.models.ts @@ -0,0 +1,241 @@ +/** + * Integration Service — Elements models + * + * Read-only catalog/metadata APIs — no entity binding. + */ + +import { + ElementObject, + ElementActivity, + ElementObjectMetadataResponse, + ElementEventObject, + ElementEventObjectMetadataResponse, + ElementObjectsGetOptions, + ElementActivitiesGetOptions, + ElementObjectMetadataGetOptions, + ElementEventObjectsGetOptions, + ElementEventObjectMetadataGetOptions, +} from './elements.types'; + +/** + * Service for inspecting connector elements (objects, activities, trigger events, + * field schemas) on UiPath Integration Service. + * + * The Elements API powers design-time tooling — every connector exposes a + * catalog of *objects* (resources like `contacts`, `messages`), *activities* + * (curated operations like `Send Email`), and *event objects* (trigger sources + * like `New Message`). Each can be inspected statically (connector-only) or + * scoped to a connection instance (which enriches the response with custom + * fields discovered from the live system). + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const elements = new Elements(sdk); + * const objects = await elements.getObjects('uipath-slack'); + * ``` + */ +export interface ElementsServiceModel { + /** + * List objects (resources) exposed by a connector. + * + * @param elementKey - Connector key (e.g. `uipath-slack`) + * @param options - Filtering options (type, subtype, hasEvents, hasBulk) + * @returns Promise resolving to an array of {@link ElementObject} + * @example + * ```typescript + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const elements = new Elements(sdk); + * + * const objects = await elements.getObjects('uipath-slack'); + * for (const obj of objects) { + * console.log(`${obj.name} — ${obj.displayName}`); + * } + * ``` + * + * @example + * ```typescript + * // Only objects that support events + * const eventCapable = await elements.getObjects('uipath-slack', { hasEvents: true }); + * ``` + */ + getObjects(elementKey: string, options?: ElementObjectsGetOptions): Promise; + + /** + * List curated activities exposed by a connector. + * + * @param elementKey - Connector key + * @param options - Optional `version` to pin to a specific connector schema + * @returns Promise resolving to an array of {@link ElementActivity} + * @example + * ```typescript + * const activities = await elements.getActivities('uipath-slack'); + * for (const activity of activities) { + * console.log(`${activity.name}: ${activity.operation} on ${activity.objectName}`); + * } + * ``` + */ + getActivities(elementKey: string, options?: ElementActivitiesGetOptions): Promise; + + /** + * Get metadata for a single connector object (field schema, supported methods, + * parameters). Connection-independent — returns the standard schema only. + * + * @param elementKey - Connector key + * @param objectName - Object name (e.g. `messages`) + * @param options - Optional `version`, `hydrateParameters`, `includeParentArray` + * @returns Promise resolving to an {@link ElementObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getObjectMetadata('uipath-slack', 'messages'); + * console.log(`Fields: ${Object.keys(meta.fields ?? {}).length}`); + * ``` + */ + getObjectMetadata( + elementKey: string, + objectName: string, + options?: ElementObjectMetadataGetOptions, + ): Promise; + + /** + * List event objects (trigger sources) for a connector's event operation. + * + * @param elementKey - Connector key + * @param operationName - Event operation name (e.g. `INDEX_COMPLETED`) + * @param options - Optional `version` + * @returns Promise resolving to an array of {@link ElementEventObject} + * @example + * ```typescript + * const events = await elements.getEventObjects('uipath-slack', 'NEW_MESSAGE'); + * ``` + */ + getEventObjects( + elementKey: string, + operationName: string, + options?: ElementEventObjectsGetOptions, + ): Promise; + + /** + * Get metadata for a single event object. + * + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param objectName - Event object name + * @param options - Optional `version`, `allFields`, `includeParentArray` + * @returns Promise resolving to an {@link ElementEventObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getEventObjectMetadata( + * 'uipath-slack', + * 'NEW_MESSAGE', + * 'channels', + * ); + * ``` + */ + getEventObjectMetadata( + elementKey: string, + operationName: string, + objectName: string, + options?: ElementEventObjectMetadataGetOptions, + ): Promise; + + /** + * List objects exposed by a connection instance (includes connector custom + * fields discovered from the live system). + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param options - Filtering options + * @returns Promise resolving to an array of {@link ElementObject} + * @example + * ```typescript + * const objects = await elements.getInstanceObjects('', 'uipath-slack'); + * ``` + */ + getInstanceObjects( + connectionId: string, + elementKey: string, + options?: ElementObjectsGetOptions, + ): Promise; + + /** + * Get instance-scoped metadata for a single object — includes connector + * custom fields discovered from the live system. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param objectName - Object name + * @param options - Optional `version`, `hydrateParameters`, `includeParentArray` + * @returns Promise resolving to an {@link ElementObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getInstanceObjectMetadata( + * '', + * 'uipath-salesforce', + * 'Account', + * ); + * ``` + */ + getInstanceObjectMetadata( + connectionId: string, + elementKey: string, + objectName: string, + options?: ElementObjectMetadataGetOptions, + ): Promise; + + /** + * List event objects for a connection instance. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param options - Optional `version` + * @returns Promise resolving to an array of {@link ElementEventObject} + * @example + * ```typescript + * const events = await elements.getInstanceEventObjects( + * '', + * 'uipath-slack', + * 'NEW_MESSAGE', + * ); + * ``` + */ + getInstanceEventObjects( + connectionId: string, + elementKey: string, + operationName: string, + options?: ElementEventObjectsGetOptions, + ): Promise; + + /** + * Get instance-scoped metadata for a single event object. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param objectName - Event object name + * @param options - Optional `version`, `allFields`, `includeParentArray` + * @returns Promise resolving to an {@link ElementEventObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getInstanceEventObjectMetadata( + * '', + * 'uipath-slack', + * 'NEW_MESSAGE', + * 'channels', + * ); + * ``` + */ + getInstanceEventObjectMetadata( + connectionId: string, + elementKey: string, + operationName: string, + objectName: string, + options?: ElementEventObjectMetadataGetOptions, + ): Promise; +} diff --git a/src/models/integration-service/elements.types.ts b/src/models/integration-service/elements.types.ts new file mode 100644 index 000000000..49ddff9ec --- /dev/null +++ b/src/models/integration-service/elements.types.ts @@ -0,0 +1,296 @@ +/** + * Integration Service — Elements (connector metadata) types. + * + * The Elements API returns deeply nested, connector-dependent shapes. We type + * the well-known fields explicitly and leave the open extension points loose + * (`Record`) so the SDK doesn't force a false-positive failure + * when a connector ships a new metadata field. + */ + +/** + * A parameter declared on a connector method (path, query, body, header, value). + */ +export interface ElementMethodParameter { + /** Parameter name (e.g. `folderKey`, `limit`). */ + name: string; + /** Where the parameter is sent. */ + type: string; + /** Parameter primitive type (`string`, `integer`, ...). */ + dataType?: string; + /** Whether the parameter is required. */ + required?: boolean; + /** Caller-facing display name. */ + displayName?: string; + /** Default value if any. */ + defaultValue?: unknown; + /** Free-form description. */ + description?: string; + /** Whether this parameter participates in curated UX. */ + curated?: boolean; + /** Cross-object reference (lookup hints). */ + reference?: Record; + /** Design-time UX hints. */ + design?: Record; + /** Sort order in UX panels. */ + sortOrder?: number; + /** Open-ended additional fields the API may ship per connector. */ + [key: string]: unknown; +} + +/** + * Describes a single HTTP method invocation on a connector object. + */ +export interface ElementMethodDefinition { + /** Logical operation name (e.g. `Create`, `List`). */ + operation?: string; + /** HTTP method. */ + method: string; + /** API path template. */ + path?: string; + /** Parameters consumed by the method. */ + parameters?: ElementMethodParameter[]; + /** OpenAPI operation ID. */ + operationId?: string; + /** Curated activity descriptor. */ + curated?: Record; + /** Friendly response label. */ + responseDisplayName?: string; + /** Design-time UX hints. */ + design?: Record; + /** Open-ended additional fields. */ + [key: string]: unknown; +} + +/** + * Connector object metadata block. The `method` map is keyed by HTTP verb. + */ +export interface ElementObjectMetadata { + /** Whether the object exposes connector-specific custom fields. */ + hasCustomFieldDiscovery?: boolean; + /** HTTP-verb-keyed map of methods supported on this object. */ + method?: Record; + /** Whether the object exposes search-friendly fields. */ + hasSearchables?: boolean; + /** Open-ended additional fields. */ + [key: string]: unknown; +} + +/** + * A connector object (resource). + */ +export interface ElementObject { + /** Object name (e.g. `contacts`). */ + name: string; + /** API path template. */ + path?: string; + /** Object type (e.g. `standard`, `custom`). */ + type?: string; + /** Object subtype. */ + subType?: string; + /** Owning connector key. */ + elementKey?: string; + /** Display name. */ + displayName?: string; + /** Connector custom-ness marker. */ + custom?: string; + /** Object metadata block. */ + metadata?: ElementObjectMetadata; + /** Whether the object is featured as a priority. */ + isPriority?: boolean; + /** Whether the object is hidden from UX. */ + isHidden?: boolean; +} + +/** + * A curated activity exposed by a connector. + */ +export interface ElementActivity { + /** Activity name. */ + name: string; + /** Display name. */ + displayName?: string; + /** Description. */ + description?: string; + /** Object the activity operates on. */ + objectName?: string; + /** HTTP method name. */ + methodName?: string; + /** Event operation (for triggers). */ + eventOperation?: string; + /** Logical operation (e.g. `List`, `Create`). */ + operation?: string; + /** Event delivery mode (e.g. `polling`, `webhooks`). */ + eventMode?: string; + /** Lifecycle stage (`GA`, `BETA`, ...). */ + lifecycleStage?: string; + /** Project types this activity is compatible with. */ + compatibleProjectTypes?: string[]; + /** Tag list. */ + tags?: string[]; + /** Subtype. */ + subType?: string; + /** Whether this activity is a trigger. */ + trigger?: boolean; + /** Whether this activity is a curated UX element. */ + curated?: boolean; + /** Trigger marker (legacy). */ + isTrigger?: boolean; + /** Open-ended additional fields. */ + [key: string]: unknown; +} + +/** + * Field descriptor on a metadata response — the value in the `fields` map, + * keyed by field name/path (e.g. `channel`, `attachment.image_url`). + * + * Shape is connector-dependent and very open-ended (type, sample value, HTTP + * method usage, lookup references, UI hints). We keep it as a free-form record. + */ +export type ElementField = Record; + +/** + * Object metadata response (returned by `getObjectMetadata` and `getInstanceObjectMetadata`). + */ +export interface ElementObjectMetadataResponse { + /** Object name. */ + name: string; + /** Path template. */ + path?: string; + /** Object type. */ + type?: string; + /** Object subtype. */ + subType?: string; + /** Owning connector key. */ + elementKey?: string; + /** Display name. */ + displayName?: string; + /** Connector custom-ness marker. */ + custom?: string; + /** Connector object metadata. */ + metadata?: ElementObjectMetadata; + /** Field schema, keyed by field name/path. */ + fields?: Record; + /** Whether the object is hidden from UX. */ + isHidden?: boolean; + /** Whether the object is featured as a priority. */ + isPriority?: boolean; +} + +/** + * An event-source object (trigger root). + */ +export interface ElementEventObject { + /** Event object name. */ + name: string; + /** Event delivery mode (`polling`, `webhooks`). */ + eventMode?: string; + /** Whether the event ID field is hidden in UX. */ + hideEventIDField?: boolean; + /** Whether the event is debug-disabled. */ + debugDisabled?: boolean; + /** Friendly display name. */ + displayName?: string; + /** Whether the webhook URL is shown in the UX. */ + isWebhookUrlVisible?: boolean; + /** Whether the event uses Bring-Your-Own-Auth. */ + byoaConnection?: boolean; + /** Whether the left-operand filter builder is static. */ + hasStaticLeftOperandFilterBuilder?: boolean; + /** Whether the event is hidden from UX. */ + isHidden?: boolean; + /** Auth types this event supports. */ + supportedAuths?: string[]; + /** Event-source parameters. */ + parameters?: ElementMethodParameter[]; + /** Logical operation. */ + operation?: string; + /** Bound object name (for events that resolve dynamically). */ + objectName?: string; + /** Whether the object name is dynamic. */ + isDynamicObjectName?: boolean; + /** Description. */ + description?: string; + /** Open-ended additional fields. */ + [key: string]: unknown; +} + +/** + * Event-object metadata response. + */ +export interface ElementEventObjectMetadataResponse { + /** Event object name. */ + name: string; + /** Path template. */ + path?: string; + /** Object type. */ + type?: string; + /** Object subtype. */ + subType?: string; + /** Owning connector key. */ + elementKey?: string; + /** Display name. */ + displayName?: string; + /** Connector custom-ness marker. */ + custom?: string; + /** Event delivery mode. */ + eventMode?: string; + /** Field schema, keyed by field name/path. */ + fields?: Record; +} + +/** + * Options for {@link ElementsServiceModel.getObjects}. + */ +export interface ElementObjectsGetOptions { + /** Filter by object type (e.g. `standard`, `custom`). */ + type?: string; + /** Filter by object subtype. */ + subType?: string; + /** Limit to objects that support events. */ + hasEvents?: boolean; + /** Limit to objects that support bulk operations. */ + hasBulk?: boolean; +} + +/** + * Options for {@link ElementsServiceModel.getActivities}. + */ +export interface ElementActivitiesGetOptions { + /** Connector schema version (defaults to the latest published). */ + version?: string; +} + +/** + * Options for {@link ElementsServiceModel.getObjectMetadata} + * and {@link ElementsServiceModel.getInstanceObjectMetadata}. + */ +export interface ElementObjectMetadataGetOptions { + /** Connector schema version (defaults to the latest published). */ + version?: string; + /** Hydrate parameters with values discovered from the connection. */ + hydrateParameters?: boolean; + /** Include parent-array references in nested objects. */ + includeParentArray?: boolean; +} + +/** + * Options for {@link ElementsServiceModel.getEventObjects} + * and {@link ElementsServiceModel.getInstanceEventObjects}. + */ +export interface ElementEventObjectsGetOptions { + /** Connector schema version (defaults to the latest published). */ + version?: string; +} + +/** + * Options for {@link ElementsServiceModel.getEventObjectMetadata} + * and {@link ElementsServiceModel.getInstanceEventObjectMetadata}. + */ +export interface ElementEventObjectMetadataGetOptions { + /** Connector schema version (defaults to the latest published). */ + version?: string; + /** Include all fields, not just curated. */ + allFields?: boolean; + /** Include parent-array references in nested objects. */ + includeParentArray?: boolean; +} diff --git a/src/models/integration-service/execution.types.ts b/src/models/integration-service/execution.types.ts new file mode 100644 index 000000000..f478f1b07 --- /dev/null +++ b/src/models/integration-service/execution.types.ts @@ -0,0 +1,42 @@ +/** + * Integration Service — Execution passthrough types. + */ + +/** + * HTTP method for an execute call. + */ +export type ExecuteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +/** + * Result envelope returned by {@link execute}. + * + * Unlike most SDK methods this *does not* throw on non-2xx responses — the + * caller inspects `ok` / `status` and `body` to handle connector-specific + * errors. This is required because the underlying Integration Service API + * proxies arbitrary third-party HTTP calls, and the body carries vendor + * error details that callers need to surface. + */ +export interface ExecuteResult { + /** True for HTTP 2xx responses. */ + ok: boolean; + /** HTTP status code from the underlying call. */ + status: number; + /** HTTP status text from the underlying call. */ + statusText: string; + /** Parsed JSON body when the response is JSON, raw text otherwise. */ + body: unknown; + /** Response headers as a flat record. */ + headers: Record; +} + +/** + * Options accepted by {@link execute}. + */ +export interface ExecuteOptions { + /** Body to send for POST/PUT/PATCH. Serialized as JSON. */ + body?: unknown; + /** Query string parameters. */ + queryParams?: Record; + /** Folder key (GUID) to scope the call via `x-uipath-folderkey`. */ + folderKey?: string; +} diff --git a/src/models/integration-service/index.ts b/src/models/integration-service/index.ts index a18531f9c..cc7ff265d 100644 --- a/src/models/integration-service/index.ts +++ b/src/models/integration-service/index.ts @@ -8,3 +8,5 @@ export * from './connectors.types'; export * from './connectors.models'; export * from './connections.types'; export * from './connections.models'; +export * from './elements.types'; +export * from './elements.models'; diff --git a/src/services/integration-service/elements/elements.ts b/src/services/integration-service/elements/elements.ts new file mode 100644 index 000000000..fd94e6606 --- /dev/null +++ b/src/services/integration-service/elements/elements.ts @@ -0,0 +1,329 @@ +import { BaseService } from '../../base'; +import { track } from '../../../core/telemetry'; +import { ValidationError } from '../../../core/errors'; +import { ELEMENT_ENDPOINTS } from '../../../utils/constants/endpoints'; +import { QueryParams } from '../../../models/common/request-spec'; +import { + ElementObject, + ElementActivity, + ElementObjectMetadataResponse, + ElementEventObject, + ElementEventObjectMetadataResponse, + ElementObjectsGetOptions, + ElementActivitiesGetOptions, + ElementObjectMetadataGetOptions, + ElementEventObjectsGetOptions, + ElementEventObjectMetadataGetOptions, +} from '../../../models/integration-service/elements.types'; +import { ElementsServiceModel } from '../../../models/integration-service/elements.models'; + +function requireArg(value: string, name: string, method: string): void { + if (!value) { + throw new ValidationError({ message: `${name} is required for ${method}` }); + } +} + +/** + * Service for inspecting connector elements (objects, activities, trigger events, + * field schemas) on UiPath Integration Service. + * + * The Elements API powers design-time tooling — every connector exposes a + * catalog of *objects* (resources like `contacts`, `messages`), *activities* + * (curated operations like `Send Email`), and *event objects* (trigger sources + * like `New Message`). Each can be inspected statically (connector-only) or + * scoped to a connection instance (which enriches the response with custom + * fields discovered from the live system). + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const elements = new Elements(sdk); + * const objects = await elements.getObjects('uipath-slack'); + * ``` + */ +export class ElementsService extends BaseService implements ElementsServiceModel { + /** + * List objects (resources) exposed by a connector. + * + * @param elementKey - Connector key (e.g. `uipath-slack`) + * @param options - Filtering options (type, subtype, hasEvents, hasBulk) + * @returns Promise resolving to an array of {@link ElementObject} + * @example + * ```typescript + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const elements = new Elements(sdk); + * + * const objects = await elements.getObjects('uipath-slack'); + * for (const obj of objects) { + * console.log(`${obj.name} — ${obj.displayName}`); + * } + * ``` + * + * @example + * ```typescript + * // Only objects that support events + * const eventCapable = await elements.getObjects('uipath-slack', { hasEvents: true }); + * ``` + */ + @track('Elements.GetObjects') + async getObjects(elementKey: string, options?: ElementObjectsGetOptions): Promise { + requireArg(elementKey, 'elementKey', 'getObjects'); + const response = await this.get(ELEMENT_ENDPOINTS.OBJECTS.LIST(elementKey), { + params: options as QueryParams | undefined, + }); + return response.data ?? []; + } + + /** + * List curated activities exposed by a connector. + * + * @param elementKey - Connector key + * @param options - Optional `version` to pin to a specific connector schema + * @returns Promise resolving to an array of {@link ElementActivity} + * @example + * ```typescript + * const activities = await elements.getActivities('uipath-slack'); + * for (const activity of activities) { + * console.log(`${activity.name}: ${activity.operation} on ${activity.objectName}`); + * } + * ``` + */ + @track('Elements.GetActivities') + async getActivities(elementKey: string, options?: ElementActivitiesGetOptions): Promise { + requireArg(elementKey, 'elementKey', 'getActivities'); + const response = await this.get(ELEMENT_ENDPOINTS.ACTIVITIES.LIST(elementKey), { + params: options as QueryParams | undefined, + }); + return response.data ?? []; + } + + /** + * Get metadata for a single connector object (field schema, supported methods, + * parameters). Connection-independent — returns the standard schema only. + * + * @param elementKey - Connector key + * @param objectName - Object name (e.g. `messages`) + * @param options - Optional `version`, `hydrateParameters`, `includeParentArray` + * @returns Promise resolving to an {@link ElementObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getObjectMetadata('uipath-slack', 'messages'); + * console.log(`Fields: ${Object.keys(meta.fields ?? {}).length}`); + * ``` + */ + @track('Elements.GetObjectMetadata') + async getObjectMetadata( + elementKey: string, + objectName: string, + options?: ElementObjectMetadataGetOptions, + ): Promise { + requireArg(elementKey, 'elementKey', 'getObjectMetadata'); + requireArg(objectName, 'objectName', 'getObjectMetadata'); + const response = await this.get( + ELEMENT_ENDPOINTS.OBJECTS.METADATA(elementKey, objectName), + { params: options as QueryParams | undefined }, + ); + return response.data; + } + + /** + * List event objects (trigger sources) for a connector's event operation. + * + * @param elementKey - Connector key + * @param operationName - Event operation name (e.g. `INDEX_COMPLETED`) + * @param options - Optional `version` + * @returns Promise resolving to an array of {@link ElementEventObject} + * @example + * ```typescript + * const events = await elements.getEventObjects('uipath-slack', 'NEW_MESSAGE'); + * ``` + */ + @track('Elements.GetEventObjects') + async getEventObjects( + elementKey: string, + operationName: string, + options?: ElementEventObjectsGetOptions, + ): Promise { + requireArg(elementKey, 'elementKey', 'getEventObjects'); + requireArg(operationName, 'operationName', 'getEventObjects'); + const response = await this.get( + ELEMENT_ENDPOINTS.EVENTS.OBJECTS(elementKey, operationName), + { params: options as QueryParams | undefined }, + ); + return response.data ?? []; + } + + /** + * Get metadata for a single event object. + * + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param objectName - Event object name + * @param options - Optional `version`, `allFields`, `includeParentArray` + * @returns Promise resolving to an {@link ElementEventObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getEventObjectMetadata( + * 'uipath-slack', + * 'NEW_MESSAGE', + * 'channels', + * ); + * ``` + */ + @track('Elements.GetEventObjectMetadata') + async getEventObjectMetadata( + elementKey: string, + operationName: string, + objectName: string, + options?: ElementEventObjectMetadataGetOptions, + ): Promise { + requireArg(elementKey, 'elementKey', 'getEventObjectMetadata'); + requireArg(operationName, 'operationName', 'getEventObjectMetadata'); + requireArg(objectName, 'objectName', 'getEventObjectMetadata'); + const response = await this.get( + ELEMENT_ENDPOINTS.EVENTS.METADATA(elementKey, operationName, objectName), + { params: options as QueryParams | undefined }, + ); + return response.data; + } + + /** + * List objects exposed by a connection instance (includes connector custom + * fields discovered from the live system). + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param options - Filtering options + * @returns Promise resolving to an array of {@link ElementObject} + * @example + * ```typescript + * const objects = await elements.getInstanceObjects('', 'uipath-slack'); + * ``` + */ + @track('Elements.GetInstanceObjects') + async getInstanceObjects( + connectionId: string, + elementKey: string, + options?: ElementObjectsGetOptions, + ): Promise { + requireArg(connectionId, 'connectionId', 'getInstanceObjects'); + requireArg(elementKey, 'elementKey', 'getInstanceObjects'); + const response = await this.get( + ELEMENT_ENDPOINTS.INSTANCE.OBJECTS.LIST(connectionId, elementKey), + { params: options as QueryParams | undefined }, + ); + return response.data ?? []; + } + + /** + * Get instance-scoped metadata for a single object — includes connector + * custom fields discovered from the live system. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param objectName - Object name + * @param options - Optional `version`, `hydrateParameters`, `includeParentArray` + * @returns Promise resolving to an {@link ElementObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getInstanceObjectMetadata( + * '', + * 'uipath-salesforce', + * 'Account', + * ); + * ``` + */ + @track('Elements.GetInstanceObjectMetadata') + async getInstanceObjectMetadata( + connectionId: string, + elementKey: string, + objectName: string, + options?: ElementObjectMetadataGetOptions, + ): Promise { + requireArg(connectionId, 'connectionId', 'getInstanceObjectMetadata'); + requireArg(elementKey, 'elementKey', 'getInstanceObjectMetadata'); + requireArg(objectName, 'objectName', 'getInstanceObjectMetadata'); + const response = await this.get( + ELEMENT_ENDPOINTS.INSTANCE.OBJECTS.METADATA(connectionId, elementKey, objectName), + { params: options as QueryParams | undefined }, + ); + return response.data; + } + + /** + * List event objects for a connection instance. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param options - Optional `version` + * @returns Promise resolving to an array of {@link ElementEventObject} + * @example + * ```typescript + * const events = await elements.getInstanceEventObjects( + * '', + * 'uipath-slack', + * 'NEW_MESSAGE', + * ); + * ``` + */ + @track('Elements.GetInstanceEventObjects') + async getInstanceEventObjects( + connectionId: string, + elementKey: string, + operationName: string, + options?: ElementEventObjectsGetOptions, + ): Promise { + requireArg(connectionId, 'connectionId', 'getInstanceEventObjects'); + requireArg(elementKey, 'elementKey', 'getInstanceEventObjects'); + requireArg(operationName, 'operationName', 'getInstanceEventObjects'); + const response = await this.get( + ELEMENT_ENDPOINTS.INSTANCE.EVENTS.OBJECTS(connectionId, elementKey, operationName), + { params: options as QueryParams | undefined }, + ); + return response.data ?? []; + } + + /** + * Get instance-scoped metadata for a single event object. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param objectName - Event object name + * @param options - Optional `version`, `allFields`, `includeParentArray` + * @returns Promise resolving to an {@link ElementEventObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getInstanceEventObjectMetadata( + * '', + * 'uipath-slack', + * 'NEW_MESSAGE', + * 'channels', + * ); + * ``` + */ + @track('Elements.GetInstanceEventObjectMetadata') + async getInstanceEventObjectMetadata( + connectionId: string, + elementKey: string, + operationName: string, + objectName: string, + options?: ElementEventObjectMetadataGetOptions, + ): Promise { + requireArg(connectionId, 'connectionId', 'getInstanceEventObjectMetadata'); + requireArg(elementKey, 'elementKey', 'getInstanceEventObjectMetadata'); + requireArg(operationName, 'operationName', 'getInstanceEventObjectMetadata'); + requireArg(objectName, 'objectName', 'getInstanceEventObjectMetadata'); + const response = await this.get( + ELEMENT_ENDPOINTS.INSTANCE.EVENTS.METADATA(connectionId, elementKey, operationName, objectName), + { params: options as QueryParams | undefined }, + ); + return response.data; + } +} diff --git a/src/services/integration-service/elements/index.ts b/src/services/integration-service/elements/index.ts new file mode 100644 index 000000000..b0217ef99 --- /dev/null +++ b/src/services/integration-service/elements/index.ts @@ -0,0 +1,22 @@ +/** + * Integration Service — Elements module. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const elements = new Elements(sdk); + * const objects = await elements.getObjects('uipath-slack'); + * ``` + * + * @module + */ + +export { ElementsService as Elements, ElementsService } from './elements'; + +export * from '../../../models/integration-service/elements.types'; +export * from '../../../models/integration-service/elements.models'; diff --git a/src/services/integration-service/execution/execution.ts b/src/services/integration-service/execution/execution.ts new file mode 100644 index 000000000..d6bd67937 --- /dev/null +++ b/src/services/integration-service/execution/execution.ts @@ -0,0 +1,154 @@ +import { track } from '../../../core/telemetry'; +import { ValidationError } from '../../../core/errors'; +import { SDKInternalsRegistry } from '../../../core/internals'; +import { ELEMENT_ENDPOINTS } from '../../../utils/constants/endpoints'; +import { FOLDER_KEY, CONTENT_TYPES } from '../../../utils/constants/headers'; +import type { IUiPath } from '../../../core/types'; +import { + ExecuteMethod, + ExecuteOptions, + ExecuteResult, +} from '../../../models/integration-service/execution.types'; + +/** + * Internal class so we can decorate `execute` with `@track`. Method decorators + * cannot be applied to free functions directly. + * + * @internal + */ +class Execution { + constructor(private readonly instance: IUiPath) {} + + @track('Execution.Execute') + async execute( + connectionId: string, + objectName: string, + method: ExecuteMethod, + options: ExecuteOptions, + ): Promise { + if (!connectionId) { + throw new ValidationError({ message: 'connectionId is required for execute' }); + } + if (!objectName) { + throw new ValidationError({ message: 'objectName is required for execute' }); + } + + const { config, tokenManager } = SDKInternalsRegistry.get(this.instance); + const token = await tokenManager.getValidToken(); + + const relativePath = ELEMENT_ENDPOINTS.INSTANCE.EXECUTE(connectionId, objectName); + const baseSegment = `${config.orgName}/${config.tenantName}/`; + const url = new URL(baseSegment + relativePath, config.baseUrl).toString(); + + let fullUrl = url; + if (options.queryParams && Object.keys(options.queryParams).length > 0) { + const qs = new URLSearchParams(options.queryParams).toString(); + const sep = url.includes('?') ? '&' : '?'; + fullUrl = `${url}${sep}${qs}`; + } + + const headers: Record = { + Authorization: `Bearer ${token}`, + }; + if (options.folderKey) { + headers[FOLDER_KEY] = options.folderKey; + } + + const hasBody = options.body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method); + if (hasBody) { + headers['Content-Type'] = CONTENT_TYPES.JSON; + } + + const response = await fetch(fullUrl, { + method, + headers, + body: hasBody ? JSON.stringify(options.body) : undefined, + }); + + const text = await response.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + } + + const flatHeaders: Record = {}; + response.headers.forEach((value, key) => { + flatHeaders[key] = value; + }); + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: parsed, + headers: flatHeaders, + }; + } +} + +/** + * Execute an arbitrary HTTP operation against a connection instance through + * the Integration Service passthrough endpoint. + * + * Targets `elements_/v3/element/instances/{connectionId}/{objectName}`. + * Use this when you need to call a connector's runtime API without going + * through a curated SDK method. + * + * Unlike standard SDK methods, **this does not throw on non-2xx responses**. + * The full HTTP envelope (`ok`, `status`, `body`, `headers`) is returned so + * callers can surface connector-specific error bodies. + * + * @param uipath - UiPath SDK instance providing authentication and configuration + * @param connectionId - Connection GUID + * @param objectName - Connector object name (e.g. `tickets`, `messages`) + * @param method - HTTP method (defaults to `GET`) + * @param options - Body, query params, and folder scoping + * @returns Promise resolving to an {@link ExecuteResult} + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { execute } from '@uipath/uipath-typescript/is-execution'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * // GET — list records + * const result = await execute(sdk, '', 'tickets', 'GET'); + * if (result.ok) { + * console.log(result.body); + * } else { + * console.error(`Connector returned ${result.status}: ${JSON.stringify(result.body)}`); + * } + * ``` + * + * @example + * ```typescript + * // POST — create a record + * const result = await execute(sdk, '', 'tickets', 'POST', { + * body: { subject: 'New ticket', priority: 'high' }, + * }); + * ``` + * + * @example + * ```typescript + * // GET with query params and folder scoping + * const result = await execute(sdk, '', 'tickets', 'GET', { + * queryParams: { limit: '10', status: 'open' }, + * folderKey: '', + * }); + * ``` + */ +export async function execute( + uipath: IUiPath, + connectionId: string, + objectName: string, + method: ExecuteMethod = 'GET', + options: ExecuteOptions = {}, +): Promise { + return new Execution(uipath).execute(connectionId, objectName, method, options); +} diff --git a/src/services/integration-service/execution/index.ts b/src/services/integration-service/execution/index.ts new file mode 100644 index 000000000..18e34abc1 --- /dev/null +++ b/src/services/integration-service/execution/index.ts @@ -0,0 +1,19 @@ +/** + * Integration Service — Execution passthrough module. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { execute } from '@uipath/uipath-typescript/is-execution'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const result = await execute(sdk, '', 'tickets', 'GET'); + * ``` + * + * @module + */ + +export { execute } from './execution'; +export * from '../../../models/integration-service/execution.types'; diff --git a/src/utils/constants/endpoints/base.ts b/src/utils/constants/endpoints/base.ts index a6977204f..199133a11 100644 --- a/src/utils/constants/endpoints/base.ts +++ b/src/utils/constants/endpoints/base.ts @@ -10,3 +10,4 @@ export const AUTOPILOT_BASE = 'autopilotforeveryone_'; export const LLMOPS_BASE = 'llmopstenant_'; export const INSIGHTS_RTM_BASE = 'insightsrtm_'; export const CONNECTIONS_BASE = 'connections_'; +export const ELEMENTS_BASE = 'elements_/v3/element'; diff --git a/src/utils/constants/endpoints/integration-service.ts b/src/utils/constants/endpoints/integration-service.ts index f3596b9c2..0d09035f0 100644 --- a/src/utils/constants/endpoints/integration-service.ts +++ b/src/utils/constants/endpoints/integration-service.ts @@ -1,10 +1,12 @@ /** * Integration Service Endpoints * - * `connections_` domain — connectors and connections (CONNECTOR_ENDPOINTS, CONNECTION_ENDPOINTS). + * Two service domains: + * - `connections_` — connectors and connections (CONNECTOR_ENDPOINTS, CONNECTION_ENDPOINTS) + * - `elements_/v3/element` — connector elements and metadata (ELEMENT_ENDPOINTS) */ -import { CONNECTIONS_BASE } from './base'; +import { CONNECTIONS_BASE, ELEMENTS_BASE } from './base'; export const CONNECTOR_ENDPOINTS = { GET_ALL: `${CONNECTIONS_BASE}/api/v1/Connectors`, @@ -19,3 +21,46 @@ export const CONNECTION_ENDPOINTS = { PING: (connectionId: string) => `${CONNECTIONS_BASE}/api/v1/Connections/${encodeURIComponent(connectionId)}/ping`, REAUTHENTICATE: (connectionId: string) => `${CONNECTIONS_BASE}/api/v1/Connections/${encodeURIComponent(connectionId)}/auth`, } as const; + +export const ELEMENT_ENDPOINTS = { + OBJECTS: { + /** List objects (resources) exposed by a connector. */ + LIST: (elementKey: string) => `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/objects`, + /** Get metadata for a single object. */ + METADATA: (elementKey: string, objectName: string) => + `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/objects/${encodeURIComponent(objectName)}/metadata`, + }, + ACTIVITIES: { + /** List curated activities exposed by a connector. */ + LIST: (elementKey: string) => `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/activities`, + }, + EVENTS: { + /** List event objects for a connector's event operation (trigger). */ + OBJECTS: (elementKey: string, operationName: string) => + `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects`, + /** Get metadata for a single event object. */ + METADATA: (elementKey: string, operationName: string, objectName: string) => + `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects/${encodeURIComponent(objectName)}/metadata`, + }, + INSTANCE: { + OBJECTS: { + /** List objects for a connection instance (includes custom fields). */ + LIST: (connectionId: string, elementKey: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/objects`, + /** Get instance-scoped metadata for a single object. */ + METADATA: (connectionId: string, elementKey: string, objectName: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/objects/${encodeURIComponent(objectName)}/metadata`, + }, + EVENTS: { + /** List event objects for a connection instance. */ + OBJECTS: (connectionId: string, elementKey: string, operationName: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects`, + /** Get instance-scoped metadata for a single event object. */ + METADATA: (connectionId: string, elementKey: string, operationName: string, objectName: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects/${encodeURIComponent(objectName)}/metadata`, + }, + /** Generic HTTP passthrough endpoint for executing a request against a connection instance. */ + EXECUTE: (connectionId: string, objectName: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/${encodeURIComponent(objectName)}`, + }, +} as const; diff --git a/tests/unit/services/integration-service/elements.test.ts b/tests/unit/services/integration-service/elements.test.ts new file mode 100644 index 000000000..d784d1e30 --- /dev/null +++ b/tests/unit/services/integration-service/elements.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ElementsService } from '../../../../src/services/integration-service/elements/elements'; +import { ELEMENT_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { ValidationError } from '../../../../src/core/errors'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { IS_TEST_CONSTANTS, createMockError, TEST_CONSTANTS } from '../../../utils/mocks'; + +vi.mock('../../../../src/core/http/api-client'); + +const EVENT_OPERATION = 'INDEX_COMPLETED'; +const OBJECT_NAME = 'indexes'; + +describe('ElementsService', () => { + let service: ElementsService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + service = new ElementsService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('static (connection-independent)', () => { + it('getObjects returns array and forwards options', async () => { + mockApiClient.get.mockResolvedValue([{ name: 'foo' }]); + const result = await service.getObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY, { hasEvents: true }); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.OBJECTS.LIST(IS_TEST_CONSTANTS.CONNECTOR_KEY), + { params: { hasEvents: true } }, + ); + expect(result).toEqual([{ name: 'foo' }]); + }); + + it('getActivities returns array', async () => { + mockApiClient.get.mockResolvedValue([{ name: 'send' }]); + const result = await service.getActivities(IS_TEST_CONSTANTS.CONNECTOR_KEY); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.ACTIVITIES.LIST(IS_TEST_CONSTANTS.CONNECTOR_KEY), + { params: undefined }, + ); + expect(result).toEqual([{ name: 'send' }]); + }); + + it('getObjectMetadata returns single metadata object', async () => { + const mockMeta = { name: OBJECT_NAME, fields: { id: { name: 'id' } } }; + mockApiClient.get.mockResolvedValue(mockMeta); + const result = await service.getObjectMetadata(IS_TEST_CONSTANTS.CONNECTOR_KEY, OBJECT_NAME, { + version: '1.0', + includeParentArray: true, + }); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.OBJECTS.METADATA(IS_TEST_CONSTANTS.CONNECTOR_KEY, OBJECT_NAME), + { params: { version: '1.0', includeParentArray: true } }, + ); + expect(result).toBe(mockMeta); + }); + + it('getEventObjects returns array', async () => { + mockApiClient.get.mockResolvedValue([{ name: OBJECT_NAME }]); + await service.getEventObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY, EVENT_OPERATION); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.EVENTS.OBJECTS(IS_TEST_CONSTANTS.CONNECTOR_KEY, EVENT_OPERATION), + { params: undefined }, + ); + }); + + it('getEventObjectMetadata returns single object', async () => { + const mockMeta = { name: OBJECT_NAME }; + mockApiClient.get.mockResolvedValue(mockMeta); + await service.getEventObjectMetadata( + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + OBJECT_NAME, + { allFields: true }, + ); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.EVENTS.METADATA(IS_TEST_CONSTANTS.CONNECTOR_KEY, EVENT_OPERATION, OBJECT_NAME), + { params: { allFields: true } }, + ); + }); + }); + + describe('instance (connection-scoped)', () => { + it('getInstanceObjects returns array', async () => { + mockApiClient.get.mockResolvedValue([{ name: 'foo' }]); + await service.getInstanceObjects(IS_TEST_CONSTANTS.CONNECTION_ID, IS_TEST_CONSTANTS.CONNECTOR_KEY); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.INSTANCE.OBJECTS.LIST( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + ), + { params: undefined }, + ); + }); + + it('getInstanceObjectMetadata returns metadata', async () => { + mockApiClient.get.mockResolvedValue({ name: OBJECT_NAME }); + await service.getInstanceObjectMetadata( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + OBJECT_NAME, + ); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.INSTANCE.OBJECTS.METADATA( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + OBJECT_NAME, + ), + { params: undefined }, + ); + }); + + it('getInstanceEventObjects returns array', async () => { + mockApiClient.get.mockResolvedValue([{ name: OBJECT_NAME }]); + await service.getInstanceEventObjects( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + ); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.INSTANCE.EVENTS.OBJECTS( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + ), + { params: undefined }, + ); + }); + + it('getInstanceEventObjectMetadata returns metadata', async () => { + mockApiClient.get.mockResolvedValue({ name: OBJECT_NAME }); + await service.getInstanceEventObjectMetadata( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + OBJECT_NAME, + ); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.INSTANCE.EVENTS.METADATA( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + OBJECT_NAME, + ), + { params: undefined }, + ); + }); + }); + + describe('validation', () => { + it('throws ValidationError when elementKey is missing', async () => { + await expect(service.getObjects('')).rejects.toThrow(ValidationError); + await expect(service.getActivities('')).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when objectName is missing', async () => { + await expect( + service.getObjectMetadata(IS_TEST_CONSTANTS.CONNECTOR_KEY, ''), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when connectionId is missing on instance reads', async () => { + await expect( + service.getInstanceObjects('', IS_TEST_CONSTANTS.CONNECTOR_KEY), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when operationName is missing on event reads', async () => { + await expect( + service.getEventObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY, ''), + ).rejects.toThrow(ValidationError); + }); + }); + + describe('error propagation', () => { + it('propagates API errors from getObjects', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + await expect(service.getObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY)).rejects.toThrow( + TEST_CONSTANTS.ERROR_MESSAGE, + ); + }); + }); +}); diff --git a/tests/unit/services/integration-service/execution.test.ts b/tests/unit/services/integration-service/execution.test.ts new file mode 100644 index 000000000..9cf9a892d --- /dev/null +++ b/tests/unit/services/integration-service/execution.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { execute } from '../../../../src/services/integration-service/execution/execution'; +import { ValidationError } from '../../../../src/core/errors'; +import { createServiceTestDependencies } from '../../../utils/setup'; +import { IS_TEST_CONSTANTS } from '../../../utils/mocks'; +import { FOLDER_KEY } from '../../../../src/utils/constants/headers'; + +const OBJECT_NAME = 'tickets'; + +interface MockableTokenManager { + getValidToken?: () => Promise; +} + +function buildDeps() { + const deps = createServiceTestDependencies(); + (deps.tokenManager as unknown as MockableTokenManager).getValidToken = vi + .fn() + .mockResolvedValue('mock-access-token'); + return deps; +} + +const buildResponse = (init: { + status?: number; + statusText?: string; + body?: string; + headers?: Record; +}) => { + const status = init.status ?? 200; + return new Response(init.body ?? '', { + status, + statusText: init.statusText ?? 'OK', + headers: init.headers ?? { 'content-type': 'application/json' }, + }); +}; + +describe('execute', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('executes a GET against the connection passthrough endpoint', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: JSON.stringify([{ id: 1 }]) })); + + const result = await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toContain(`/elements_/v3/element/instances/${IS_TEST_CONSTANTS.CONNECTION_ID}/${OBJECT_NAME}`); + expect(init.method).toBe('GET'); + expect(init.headers).toMatchObject({ Authorization: expect.stringMatching(/^Bearer /) }); + expect(init.body).toBeUndefined(); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.body).toEqual([{ id: 1 }]); + }); + + it('serializes JSON body for POST', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: JSON.stringify({ id: 42 }) })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'POST', { + body: { subject: 'New' }, + }); + + const [, init] = fetchSpy.mock.calls[0]; + expect(init.method).toBe('POST'); + expect(init.headers['Content-Type']).toBe('application/json'); + expect(init.body).toBe('{"subject":"New"}'); + }); + + it('appends query params to the URL', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: '[]' })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'GET', { + queryParams: { limit: '10', status: 'open' }, + }); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('limit=10'); + expect(url).toContain('status=open'); + }); + + it('sends folder header when folderKey is provided', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: '[]' })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'GET', { + folderKey: IS_TEST_CONSTANTS.FOLDER_KEY, + }); + + const [, init] = fetchSpy.mock.calls[0]; + expect(init.headers[FOLDER_KEY]).toBe(IS_TEST_CONSTANTS.FOLDER_KEY); + }); + + it('returns full envelope on non-2xx without throwing', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue( + buildResponse({ + status: 400, + statusText: 'Bad Request', + body: JSON.stringify({ error: 'invalid' }), + }), + ); + + const result = await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME); + + expect(result.ok).toBe(false); + expect(result.status).toBe(400); + expect(result.statusText).toBe('Bad Request'); + expect(result.body).toEqual({ error: 'invalid' }); + }); + + it('returns raw text when response body is not JSON', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue( + buildResponse({ body: 'plain text', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME); + + expect(result.body).toBe('plain text'); + }); + + it('throws ValidationError when connectionId is missing', async () => { + const { instance } = buildDeps(); + await expect( + execute(instance, '', OBJECT_NAME), + ).rejects.toThrow(ValidationError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when objectName is missing', async () => { + const { instance } = buildDeps(); + await expect( + execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, ''), + ).rejects.toThrow(ValidationError); + }); +}); From 6b1fe44f59aa7d72d345863e82e06c57430d1dc0 Mon Sep 17 00:00:00 2001 From: Anil Kumar Alanka Date: Wed, 22 Jul 2026 14:42:51 +0530 Subject: [PATCH 4/5] fix: object name encoding in execute method execute() built the passthrough URL with encodeURIComponent() over the whole objectName, turning the '/' in a multi-segment path (e.g. curated_get_issue/APPS-34728) into %2F, which the connector runtime could not route (404). Encode each segment independently so reserved characters within a segment are still escaped while the '/' separators are preserved. Adds regression tests for both behaviors. --- .../endpoints/integration-service.ts | 13 ++++++++-- .../integration-service/execution.test.ts | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/utils/constants/endpoints/integration-service.ts b/src/utils/constants/endpoints/integration-service.ts index 0d09035f0..4f31a1952 100644 --- a/src/utils/constants/endpoints/integration-service.ts +++ b/src/utils/constants/endpoints/integration-service.ts @@ -59,8 +59,17 @@ export const ELEMENT_ENDPOINTS = { METADATA: (connectionId: string, elementKey: string, operationName: string, objectName: string) => `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects/${encodeURIComponent(objectName)}/metadata`, }, - /** Generic HTTP passthrough endpoint for executing a request against a connection instance. */ + /** + * Generic HTTP passthrough endpoint for executing a request against a connection instance. + * + * `objectName` may be a multi-segment path (e.g. `curated_get_issue/APPS-34728`). + * Each segment is encoded independently so reserved characters within a segment are + * escaped while the `/` separators that make up the path are preserved. + */ EXECUTE: (connectionId: string, objectName: string) => - `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/${encodeURIComponent(objectName)}`, + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/${objectName + .split('/') + .map(encodeURIComponent) + .join('/')}`, }, } as const; diff --git a/tests/unit/services/integration-service/execution.test.ts b/tests/unit/services/integration-service/execution.test.ts index 9cf9a892d..2c903aa87 100644 --- a/tests/unit/services/integration-service/execution.test.ts +++ b/tests/unit/services/integration-service/execution.test.ts @@ -131,6 +131,30 @@ describe('execute', () => { expect(result.body).toBe('plain text'); }); + it('preserves "/" separators in a multi-segment objectName', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: '{}' })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, 'curated_get_issue/APPS-34728'); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain( + `/elements_/v3/element/instances/${IS_TEST_CONSTANTS.CONNECTION_ID}/curated_get_issue/APPS-34728`, + ); + expect(url).not.toContain('%2F'); + }); + + it('encodes reserved characters within each path segment', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: '{}' })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, 'curated_get_issue/APPS 347#28'); + + const [url] = fetchSpy.mock.calls[0]; + // separator stays literal, but the space and "#" inside the segment are escaped + expect(url).toContain('/curated_get_issue/APPS%20347%2328'); + }); + it('throws ValidationError when connectionId is missing', async () => { const { instance } = buildDeps(); await expect( From 981ee06f78f500121c792e1caee260c655b4a2fa Mon Sep 17 00:00:00 2001 From: Anil Kumar Alanka Date: Thu, 23 Jul 2026 09:07:38 +0530 Subject: [PATCH 5/5] fix: address stacked-PR review comments (#555, #556, #557) Execution service (#557): - Extend BaseService; acquire token via getValidAuthToken() instead of reaching into SDKInternalsRegistry for the token manager (config-only registry access retained in the constructor). - Send W3C tracing headers (traceparent / x-uipath-traceparent-id) on the raw passthrough fetch. - Extract ['POST','PUT','PATCH'] into a module-level BODY_METHODS constant. - Drop the `as unknown as` cast in tests; shared mock TokenManager now exposes getValidToken. Connectors + Elements (#555): - ConnectorGetResponse: single-type alias -> `interface extends` for TypeDoc. - Add LifeCycleStage enum (PREVIEW, GA, DEPRECATED, CUSTOM) and type Connector.lifeCycleStage / ElementActivity.lifecycleStage with it. - Add error-propagation tests for getDefaultConnection and getConnections. - Add error-propagation tests for the 8 remaining Elements read methods. OAuth scopes (#556): - Update docs to the new IS.* scope names (per-service): Connectors -> IS.Connectors.Read, Connections -> IS.Connections.Read, Elements -> IS.Connector.Export, Execution -> IS.Connections.Read. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/oauth-scopes.md | 36 +++++----- .../integration-service/connectors.models.ts | 2 +- .../integration-service/connectors.types.ts | 14 +++- .../integration-service/elements.types.ts | 6 +- .../execution/execution.ts | 37 ++++++++--- .../integration-service/connectors.test.ts | 14 ++++ .../integration-service/elements.test.ts | 66 ++++++++++++++++++- .../integration-service/execution.test.ts | 46 ++++++------- tests/utils/constants/integration-service.ts | 4 +- tests/utils/setup.ts | 2 + 10 files changed, 171 insertions(+), 56 deletions(-) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index c97f9a4d3..e0407c41a 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -6,39 +6,39 @@ This page lists the specific OAuth scopes required in external app for each SDK | Method | OAuth Scope | |--------|-------------| -| `getAll()` | `ConnectionService` or `ConnectionServiceUser` | -| `getById()` | `ConnectionService` or `ConnectionServiceUser` | -| `getDefaultConnection()` | `ConnectionService` or `ConnectionServiceUser` | -| `getConnections()` | `ConnectionService` or `ConnectionServiceUser` | +| `getAll()` | `IS.Connectors.Read` | +| `getById()` | `IS.Connectors.Read` | +| `getDefaultConnection()` | `IS.Connectors.Read` | +| `getConnections()` | `IS.Connectors.Read` | ## Integration Service — Connections | Method | OAuth Scope | |--------|-------------| -| `getAll()` | `ConnectionService` or `ConnectionServiceUser` | -| `getById()` | `ConnectionService` or `ConnectionServiceUser` | -| `ping()` | `ConnectionService` or `ConnectionServiceUser` | -| `reauthenticate()` | `ConnectionService` | +| `getAll()` | `IS.Connections.Read` | +| `getById()` | `IS.Connections.Read` | +| `ping()` | `IS.Connections.Read` | +| `reauthenticate()` | `IS.Connections.Read` | ## Integration Service — Elements | Method | OAuth Scope | |--------|-------------| -| `getObjects()` | `ConnectionService` or `ConnectionServiceUser` | -| `getActivities()` | `ConnectionService` or `ConnectionServiceUser` | -| `getObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | -| `getEventObjects()` | `ConnectionService` or `ConnectionServiceUser` | -| `getEventObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | -| `getInstanceObjects()` | `ConnectionService` or `ConnectionServiceUser` | -| `getInstanceObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | -| `getInstanceEventObjects()` | `ConnectionService` or `ConnectionServiceUser` | -| `getInstanceEventObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | +| `getObjects()` | `IS.Connector.Export` | +| `getActivities()` | `IS.Connector.Export` | +| `getObjectMetadata()` | `IS.Connector.Export` | +| `getEventObjects()` | `IS.Connector.Export` | +| `getEventObjectMetadata()` | `IS.Connector.Export` | +| `getInstanceObjects()` | `IS.Connector.Export` | +| `getInstanceObjectMetadata()` | `IS.Connector.Export` | +| `getInstanceEventObjects()` | `IS.Connector.Export` | +| `getInstanceEventObjectMetadata()` | `IS.Connector.Export` | ## Integration Service — Execution | Function | OAuth Scope | |----------|-------------| -| `execute()` | `ConnectionService` or `ConnectionServiceUser` (plus any third-party scopes required by the underlying connection) | +| `execute()` | `IS.Connections.Read` (plus any third-party scopes required by the underlying connection) | ## Assets diff --git a/src/models/integration-service/connectors.models.ts b/src/models/integration-service/connectors.models.ts index 3f4fabc89..7b0d9b2f9 100644 --- a/src/models/integration-service/connectors.models.ts +++ b/src/models/integration-service/connectors.models.ts @@ -16,7 +16,7 @@ import { ConnectionGetResponse } from './connections.models'; /** * A connector catalog entry from the Integration Service. */ -export type ConnectorGetResponse = RawConnectorGetResponse; +export interface ConnectorGetResponse extends RawConnectorGetResponse {} /** * Service for inspecting UiPath Integration Service connectors. diff --git a/src/models/integration-service/connectors.types.ts b/src/models/integration-service/connectors.types.ts index 7d3556d52..ae27b046c 100644 --- a/src/models/integration-service/connectors.types.ts +++ b/src/models/integration-service/connectors.types.ts @@ -4,6 +4,16 @@ * Types for the Connectors API (`connections_/api/v1/Connectors`). */ +/** + * Lifecycle stage of a connector. + */ +export enum LifeCycleStage { + PREVIEW = 'PREVIEW', + GA = 'GA', + DEPRECATED = 'DEPRECATED', + CUSTOM = 'CUSTOM', +} + /** * Authentication scheme descriptor on a connector (`oauth2`, `basic`, `apiKey`, ...). * Shape is connector-dependent; exposed as a record so the SDK doesn't impose @@ -44,8 +54,8 @@ export interface RawConnectorGetResponse { hasEvents?: boolean; /** Event types the connector emits. */ eventTypes?: string[]; - /** Lifecycle stage (e.g. `GA`, `BETA`, `CUSTOM`). */ - lifeCycleStage?: string; + /** Lifecycle stage. */ + lifeCycleStage?: LifeCycleStage; /** Tag string (comma-separated). */ tags?: string; /** Connector classification (e.g. `application`, `database`). */ diff --git a/src/models/integration-service/elements.types.ts b/src/models/integration-service/elements.types.ts index 49ddff9ec..108300793 100644 --- a/src/models/integration-service/elements.types.ts +++ b/src/models/integration-service/elements.types.ts @@ -7,6 +7,8 @@ * when a connector ships a new metadata field. */ +import { LifeCycleStage } from './connectors.types'; + /** * A parameter declared on a connector method (path, query, body, header, value). */ @@ -121,8 +123,8 @@ export interface ElementActivity { operation?: string; /** Event delivery mode (e.g. `polling`, `webhooks`). */ eventMode?: string; - /** Lifecycle stage (`GA`, `BETA`, ...). */ - lifecycleStage?: string; + /** Lifecycle stage. */ + lifecycleStage?: LifeCycleStage; /** Project types this activity is compatible with. */ compatibleProjectTypes?: string[]; /** Tag list. */ diff --git a/src/services/integration-service/execution/execution.ts b/src/services/integration-service/execution/execution.ts index d6bd67937..11d233efe 100644 --- a/src/services/integration-service/execution/execution.ts +++ b/src/services/integration-service/execution/execution.ts @@ -1,23 +1,39 @@ +import { BaseService } from '../../base'; import { track } from '../../../core/telemetry'; import { ValidationError } from '../../../core/errors'; import { SDKInternalsRegistry } from '../../../core/internals'; import { ELEMENT_ENDPOINTS } from '../../../utils/constants/endpoints'; -import { FOLDER_KEY, CONTENT_TYPES } from '../../../utils/constants/headers'; +import { FOLDER_KEY, CONTENT_TYPES, TRACEPARENT, UIPATH_TRACEPARENT_ID } from '../../../utils/constants/headers'; import type { IUiPath } from '../../../core/types'; +import type { UiPathConfig } from '../../../core/config/config'; import { ExecuteMethod, ExecuteOptions, ExecuteResult, } from '../../../models/integration-service/execution.types'; +/** HTTP methods that carry a request body. */ +const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']); + /** * Internal class so we can decorate `execute` with `@track`. Method decorators * cannot be applied to free functions directly. * * @internal */ -class Execution { - constructor(private readonly instance: IUiPath) {} +class Execution extends BaseService { + private readonly execConfig: Pick; + + /** + * Creates an instance of the Execution service. + * + * @param instance - UiPath SDK instance providing authentication and configuration + */ + constructor(instance: IUiPath) { + super(instance); + const { config } = SDKInternalsRegistry.get(instance); + this.execConfig = config; + } @track('Execution.Execute') async execute( @@ -33,12 +49,11 @@ class Execution { throw new ValidationError({ message: 'objectName is required for execute' }); } - const { config, tokenManager } = SDKInternalsRegistry.get(this.instance); - const token = await tokenManager.getValidToken(); + const token = await this.getValidAuthToken(); const relativePath = ELEMENT_ENDPOINTS.INSTANCE.EXECUTE(connectionId, objectName); - const baseSegment = `${config.orgName}/${config.tenantName}/`; - const url = new URL(baseSegment + relativePath, config.baseUrl).toString(); + const baseSegment = `${this.execConfig.orgName}/${this.execConfig.tenantName}/`; + const url = new URL(baseSegment + relativePath, this.execConfig.baseUrl).toString(); let fullUrl = url; if (options.queryParams && Object.keys(options.queryParams).length > 0) { @@ -47,14 +62,20 @@ class Execution { fullUrl = `${url}${sep}${qs}`; } + const traceId = crypto.randomUUID().replace(/-/g, ''); + const spanId = crypto.randomUUID().replace(/-/g, '').slice(0, 16); + const traceparentValue = `00-${traceId}-${spanId}-01`; + const headers: Record = { Authorization: `Bearer ${token}`, + [TRACEPARENT]: traceparentValue, + [UIPATH_TRACEPARENT_ID]: traceparentValue, }; if (options.folderKey) { headers[FOLDER_KEY] = options.folderKey; } - const hasBody = options.body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method); + const hasBody = options.body !== undefined && BODY_METHODS.has(method); if (hasBody) { headers['Content-Type'] = CONTENT_TYPES.JSON; } diff --git a/tests/unit/services/integration-service/connectors.test.ts b/tests/unit/services/integration-service/connectors.test.ts index b8793a677..dcfb9bcc6 100644 --- a/tests/unit/services/integration-service/connectors.test.ts +++ b/tests/unit/services/integration-service/connectors.test.ts @@ -141,6 +141,13 @@ describe('ConnectorsService', () => { it('should throw ValidationError when keyOrId is empty', async () => { await expect(service.getDefaultConnection('')).rejects.toThrow(ValidationError); }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(IS_TEST_CONSTANTS.ERROR_CONNECTION_NOT_FOUND)); + await expect(service.getDefaultConnection(IS_TEST_CONSTANTS.CONNECTOR_KEY)).rejects.toThrow( + IS_TEST_CONSTANTS.ERROR_CONNECTION_NOT_FOUND, + ); + }); }); describe('getConnections', () => { @@ -179,5 +186,12 @@ describe('ConnectorsService', () => { it('should throw ValidationError when keyOrId is empty', async () => { await expect(service.getConnections('')).rejects.toThrow(ValidationError); }); + + it('should propagate API errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + await expect(service.getConnections(IS_TEST_CONSTANTS.CONNECTOR_KEY)).rejects.toThrow( + TEST_CONSTANTS.ERROR_MESSAGE, + ); + }); }); }); diff --git a/tests/unit/services/integration-service/elements.test.ts b/tests/unit/services/integration-service/elements.test.ts index d784d1e30..21a96350d 100644 --- a/tests/unit/services/integration-service/elements.test.ts +++ b/tests/unit/services/integration-service/elements.test.ts @@ -179,11 +179,75 @@ describe('ElementsService', () => { }); describe('error propagation', () => { - it('propagates API errors from getObjects', async () => { + beforeEach(() => { mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + }); + + it('propagates API errors from getObjects', async () => { await expect(service.getObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY)).rejects.toThrow( TEST_CONSTANTS.ERROR_MESSAGE, ); }); + + it('propagates API errors from getActivities', async () => { + await expect(service.getActivities(IS_TEST_CONSTANTS.CONNECTOR_KEY)).rejects.toThrow( + TEST_CONSTANTS.ERROR_MESSAGE, + ); + }); + + it('propagates API errors from getObjectMetadata', async () => { + await expect( + service.getObjectMetadata(IS_TEST_CONSTANTS.CONNECTOR_KEY, OBJECT_NAME), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + + it('propagates API errors from getEventObjects', async () => { + await expect( + service.getEventObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY, EVENT_OPERATION), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + + it('propagates API errors from getEventObjectMetadata', async () => { + await expect( + service.getEventObjectMetadata(IS_TEST_CONSTANTS.CONNECTOR_KEY, EVENT_OPERATION, OBJECT_NAME), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + + it('propagates API errors from getInstanceObjects', async () => { + await expect( + service.getInstanceObjects(IS_TEST_CONSTANTS.CONNECTION_ID, IS_TEST_CONSTANTS.CONNECTOR_KEY), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + + it('propagates API errors from getInstanceObjectMetadata', async () => { + await expect( + service.getInstanceObjectMetadata( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + OBJECT_NAME, + ), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + + it('propagates API errors from getInstanceEventObjects', async () => { + await expect( + service.getInstanceEventObjects( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + ), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + + it('propagates API errors from getInstanceEventObjectMetadata', async () => { + await expect( + service.getInstanceEventObjectMetadata( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + OBJECT_NAME, + ), + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); }); }); diff --git a/tests/unit/services/integration-service/execution.test.ts b/tests/unit/services/integration-service/execution.test.ts index 2c903aa87..f6e5a2baf 100644 --- a/tests/unit/services/integration-service/execution.test.ts +++ b/tests/unit/services/integration-service/execution.test.ts @@ -3,22 +3,10 @@ import { execute } from '../../../../src/services/integration-service/execution/ import { ValidationError } from '../../../../src/core/errors'; import { createServiceTestDependencies } from '../../../utils/setup'; import { IS_TEST_CONSTANTS } from '../../../utils/mocks'; -import { FOLDER_KEY } from '../../../../src/utils/constants/headers'; +import { FOLDER_KEY, TRACEPARENT, UIPATH_TRACEPARENT_ID } from '../../../../src/utils/constants/headers'; const OBJECT_NAME = 'tickets'; -interface MockableTokenManager { - getValidToken?: () => Promise; -} - -function buildDeps() { - const deps = createServiceTestDependencies(); - (deps.tokenManager as unknown as MockableTokenManager).getValidToken = vi - .fn() - .mockResolvedValue('mock-access-token'); - return deps; -} - const buildResponse = (init: { status?: number; statusText?: string; @@ -47,7 +35,7 @@ describe('execute', () => { }); it('executes a GET against the connection passthrough endpoint', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); fetchSpy.mockResolvedValue(buildResponse({ body: JSON.stringify([{ id: 1 }]) })); const result = await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME); @@ -64,7 +52,7 @@ describe('execute', () => { }); it('serializes JSON body for POST', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); fetchSpy.mockResolvedValue(buildResponse({ body: JSON.stringify({ id: 42 }) })); await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'POST', { @@ -78,7 +66,7 @@ describe('execute', () => { }); it('appends query params to the URL', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); fetchSpy.mockResolvedValue(buildResponse({ body: '[]' })); await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'GET', { @@ -91,7 +79,7 @@ describe('execute', () => { }); it('sends folder header when folderKey is provided', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); fetchSpy.mockResolvedValue(buildResponse({ body: '[]' })); await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'GET', { @@ -102,8 +90,20 @@ describe('execute', () => { expect(init.headers[FOLDER_KEY]).toBe(IS_TEST_CONSTANTS.FOLDER_KEY); }); + it('sends distributed-tracing headers on every request', async () => { + const { instance } = createServiceTestDependencies(); + fetchSpy.mockResolvedValue(buildResponse({ body: '[]' })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME); + + const [, init] = fetchSpy.mock.calls[0]; + // W3C traceparent: 00-<32 hex>-<16 hex>-01, mirrored on the UiPath header + expect(init.headers[TRACEPARENT]).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/); + expect(init.headers[UIPATH_TRACEPARENT_ID]).toBe(init.headers[TRACEPARENT]); + }); + it('returns full envelope on non-2xx without throwing', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); fetchSpy.mockResolvedValue( buildResponse({ status: 400, @@ -121,7 +121,7 @@ describe('execute', () => { }); it('returns raw text when response body is not JSON', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); fetchSpy.mockResolvedValue( buildResponse({ body: 'plain text', headers: { 'content-type': 'text/plain' } }), ); @@ -132,7 +132,7 @@ describe('execute', () => { }); it('preserves "/" separators in a multi-segment objectName', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); fetchSpy.mockResolvedValue(buildResponse({ body: '{}' })); await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, 'curated_get_issue/APPS-34728'); @@ -145,7 +145,7 @@ describe('execute', () => { }); it('encodes reserved characters within each path segment', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); fetchSpy.mockResolvedValue(buildResponse({ body: '{}' })); await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, 'curated_get_issue/APPS 347#28'); @@ -156,7 +156,7 @@ describe('execute', () => { }); it('throws ValidationError when connectionId is missing', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); await expect( execute(instance, '', OBJECT_NAME), ).rejects.toThrow(ValidationError); @@ -164,7 +164,7 @@ describe('execute', () => { }); it('throws ValidationError when objectName is missing', async () => { - const { instance } = buildDeps(); + const { instance } = createServiceTestDependencies(); await expect( execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, ''), ).rejects.toThrow(ValidationError); diff --git a/tests/utils/constants/integration-service.ts b/tests/utils/constants/integration-service.ts index e801dfbf5..ea16ea818 100644 --- a/tests/utils/constants/integration-service.ts +++ b/tests/utils/constants/integration-service.ts @@ -2,12 +2,14 @@ * Integration Service test constants. */ +import { LifeCycleStage } from '../../../src/models/integration-service/connectors.types'; + export const IS_TEST_CONSTANTS = { CONNECTOR_KEY: 'uipath-uipath-airdk', CONNECTOR_ID: 7074, CONNECTOR_NAME: 'UiPath GenAI Activities', CONNECTOR_TIER: '2', - CONNECTOR_LIFECYCLE_GA: 'GA', + CONNECTOR_LIFECYCLE_GA: LifeCycleStage.GA, CONNECTION_ID: '29d931e5-00a5-45e9-b5e9-b9bd80844396', CONNECTION_ID_2: 'b4022e19-3007-4383-b664-30444b156286', CONNECTION_NAME: 'UiPath GenAI Activities', diff --git a/tests/utils/setup.ts b/tests/utils/setup.ts index 23f349377..da2a039da 100644 --- a/tests/utils/setup.ts +++ b/tests/utils/setup.ts @@ -22,6 +22,7 @@ interface MockableUiPath { interface MockableTokenManager { getToken: () => string | undefined; hasValidToken: () => boolean; + getValidToken: () => Promise; } // Mock console methods to avoid test output noise @@ -67,6 +68,7 @@ const createMockTokenManager = (overrides?: Partial): Toke const mock: MockableTokenManager = { getToken: vi.fn().mockReturnValue('mock-access-token'), hasValidToken: vi.fn().mockReturnValue(true), + getValidToken: vi.fn().mockResolvedValue(TEST_CONSTANTS.DEFAULT_ACCESS_TOKEN), ...overrides, }; return mock as TokenManager;