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, + ); +};