diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 783259d6f..e0407c41a 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -2,6 +2,44 @@ This page lists the specific OAuth scopes required in external app for each SDK method. +## Integration Service — Connectors + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `IS.Connectors.Read` | +| `getById()` | `IS.Connectors.Read` | +| `getDefaultConnection()` | `IS.Connectors.Read` | +| `getConnections()` | `IS.Connectors.Read` | + +## Integration Service — Connections + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `IS.Connections.Read` | +| `getById()` | `IS.Connections.Read` | +| `ping()` | `IS.Connections.Read` | +| `reauthenticate()` | `IS.Connections.Read` | + +## Integration Service — Elements + +| Method | OAuth Scope | +|--------|-------------| +| `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()` | `IS.Connections.Read` (plus any third-party scopes required by the underlying connection) | + ## Assets | Method | OAuth Scope | diff --git a/mkdocs.yml b/mkdocs.yml index dbe06530a..85a9e08dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -199,6 +199,10 @@ nav: - api/interfaces/entity/index.md - Choice Sets: api/interfaces/ChoiceSetServiceModel.md - Governance: api/interfaces/GovernanceServiceModel.md + - 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 c6525348d..469815da2 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,46 @@ "types": "./dist/governance/index.d.ts", "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-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", + "default": "./dist/is-connections/index.mjs" + }, + "require": { + "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 aa42cc39b..4ee3bdb83 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -228,6 +228,26 @@ const serviceEntries = [ name: 'governance', 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-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/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/connectors.models.ts b/src/models/integration-service/connectors.models.ts new file mode 100644 index 000000000..7b0d9b2f9 --- /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 interface ConnectorGetResponse extends 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..ae27b046c --- /dev/null +++ b/src/models/integration-service/connectors.types.ts @@ -0,0 +1,126 @@ +/** + * Integration Service — Connector types + * + * 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 + * 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. */ + lifeCycleStage?: LifeCycleStage; + /** 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/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..108300793 --- /dev/null +++ b/src/models/integration-service/elements.types.ts @@ -0,0 +1,298 @@ +/** + * 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. + */ + +import { LifeCycleStage } from './connectors.types'; + +/** + * 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. */ + lifecycleStage?: LifeCycleStage; + /** 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 new file mode 100644 index 000000000..cc7ff265d --- /dev/null +++ b/src/models/integration-service/index.ts @@ -0,0 +1,12 @@ +/** + * Integration Service models barrel. + * + * Internal-types files are intentionally not re-exported. + */ + +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/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/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/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..11d233efe --- /dev/null +++ b/src/services/integration-service/execution/execution.ts @@ -0,0 +1,175 @@ +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, 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 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( + 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 token = await this.getValidAuthToken(); + + const relativePath = ELEMENT_ENDPOINTS.INSTANCE.EXECUTE(connectionId, objectName); + 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) { + const qs = new URLSearchParams(options.queryParams).toString(); + const sep = url.includes('?') ? '&' : '?'; + 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 && BODY_METHODS.has(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 6bfa8f393..199133a11 100644 --- a/src/utils/constants/endpoints/base.ts +++ b/src/utils/constants/endpoints/base.ts @@ -9,3 +9,5 @@ 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_'; +export const ELEMENTS_BASE = 'elements_/v3/element'; 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..4f31a1952 --- /dev/null +++ b/src/utils/constants/endpoints/integration-service.ts @@ -0,0 +1,75 @@ +/** + * Integration Service Endpoints + * + * Two service domains: + * - `connections_` — connectors and connections (CONNECTOR_ENDPOINTS, CONNECTION_ENDPOINTS) + * - `elements_/v3/element` — connector elements and metadata (ELEMENT_ENDPOINTS) + */ + +import { CONNECTIONS_BASE, ELEMENTS_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)}`, + 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. + * + * `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)}/${objectName + .split('/') + .map(encodeURIComponent) + .join('/')}`, + }, +} 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/unit/services/integration-service/connectors.test.ts b/tests/unit/services/integration-service/connectors.test.ts new file mode 100644 index 000000000..dcfb9bcc6 --- /dev/null +++ b/tests/unit/services/integration-service/connectors.test.ts @@ -0,0 +1,197 @@ +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); + }); + + 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', () => { + 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); + }); + + 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 new file mode 100644 index 000000000..21a96350d --- /dev/null +++ b/tests/unit/services/integration-service/elements.test.ts @@ -0,0 +1,253 @@ +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', () => { + 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 new file mode 100644 index 000000000..f6e5a2baf --- /dev/null +++ b/tests/unit/services/integration-service/execution.test.ts @@ -0,0 +1,172 @@ +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, TRACEPARENT, UIPATH_TRACEPARENT_ID } from '../../../../src/utils/constants/headers'; + +const OBJECT_NAME = 'tickets'; + +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 } = createServiceTestDependencies(); + 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 } = createServiceTestDependencies(); + 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 } = createServiceTestDependencies(); + 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 } = createServiceTestDependencies(); + 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('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 } = createServiceTestDependencies(); + 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 } = createServiceTestDependencies(); + 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('preserves "/" separators in a multi-segment objectName', async () => { + const { instance } = createServiceTestDependencies(); + 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 } = createServiceTestDependencies(); + 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 } = createServiceTestDependencies(); + await expect( + execute(instance, '', OBJECT_NAME), + ).rejects.toThrow(ValidationError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when objectName is missing', async () => { + const { instance } = createServiceTestDependencies(); + await expect( + execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, ''), + ).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..ea16ea818 --- /dev/null +++ b/tests/utils/constants/integration-service.ts @@ -0,0 +1,28 @@ +/** + * 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: LifeCycleStage.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..90a093b0e --- /dev/null +++ b/tests/utils/mocks/integration-service.ts @@ -0,0 +1,84 @@ +/** + * 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 { 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. + */ +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, + ); +}; 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;