diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 02199bced..c97f9a4d3 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -20,6 +20,26 @@ This page lists the specific OAuth scopes required in external app for each SDK | `ping()` | `ConnectionService` or `ConnectionServiceUser` | | `reauthenticate()` | `ConnectionService` | +## Integration Service — Elements + +| Method | OAuth Scope | +|--------|-------------| +| `getObjects()` | `ConnectionService` or `ConnectionServiceUser` | +| `getActivities()` | `ConnectionService` or `ConnectionServiceUser` | +| `getObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | +| `getEventObjects()` | `ConnectionService` or `ConnectionServiceUser` | +| `getEventObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | +| `getInstanceObjects()` | `ConnectionService` or `ConnectionServiceUser` | +| `getInstanceObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | +| `getInstanceEventObjects()` | `ConnectionService` or `ConnectionServiceUser` | +| `getInstanceEventObjectMetadata()` | `ConnectionService` or `ConnectionServiceUser` | + +## Integration Service — Execution + +| Function | OAuth Scope | +|----------|-------------| +| `execute()` | `ConnectionService` or `ConnectionServiceUser` (plus any third-party scopes required by the underlying connection) | + ## Assets | Method | OAuth Scope | diff --git a/mkdocs.yml b/mkdocs.yml index a00358b19..85a9e08dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -202,6 +202,7 @@ nav: - Integration Service: - Connectors: api/interfaces/ConnectorsServiceModel.md - Connections: api/interfaces/ConnectionsServiceModel.md + - Elements: api/interfaces/ElementsServiceModel.md - Maestro: - Processes: api/interfaces/MaestroProcessesServiceModel.md - Process Instances: api/interfaces/ProcessInstancesServiceModel.md diff --git a/package.json b/package.json index 24069cbd6..469815da2 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,16 @@ "default": "./dist/is-connectors/index.cjs" } }, + "./is-elements": { + "import": { + "types": "./dist/is-elements/index.d.ts", + "default": "./dist/is-elements/index.mjs" + }, + "require": { + "types": "./dist/is-elements/index.d.ts", + "default": "./dist/is-elements/index.cjs" + } + }, "./is-connections": { "import": { "types": "./dist/is-connections/index.d.ts", @@ -235,6 +245,16 @@ "types": "./dist/is-connections/index.d.ts", "default": "./dist/is-connections/index.cjs" } + }, + "./is-execution": { + "import": { + "types": "./dist/is-execution/index.d.ts", + "default": "./dist/is-execution/index.mjs" + }, + "require": { + "types": "./dist/is-execution/index.d.ts", + "default": "./dist/is-execution/index.cjs" + } } }, "files": [ diff --git a/rollup.config.js b/rollup.config.js index 7af530ad0..4ee3bdb83 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -234,10 +234,20 @@ const serviceEntries = [ input: 'src/services/integration-service/connectors/index.ts', output: 'is-connectors/index' }, + { + name: 'is-elements', + input: 'src/services/integration-service/elements/index.ts', + output: 'is-elements/index' + }, { name: 'is-connections', input: 'src/services/integration-service/connections/index.ts', output: 'is-connections/index' + }, + { + name: 'is-execution', + input: 'src/services/integration-service/execution/index.ts', + output: 'is-execution/index' } ]; diff --git a/src/models/integration-service/elements.models.ts b/src/models/integration-service/elements.models.ts new file mode 100644 index 000000000..f62fabbdd --- /dev/null +++ b/src/models/integration-service/elements.models.ts @@ -0,0 +1,241 @@ +/** + * Integration Service — Elements models + * + * Read-only catalog/metadata APIs — no entity binding. + */ + +import { + ElementObject, + ElementActivity, + ElementObjectMetadataResponse, + ElementEventObject, + ElementEventObjectMetadataResponse, + ElementObjectsGetOptions, + ElementActivitiesGetOptions, + ElementObjectMetadataGetOptions, + ElementEventObjectsGetOptions, + ElementEventObjectMetadataGetOptions, +} from './elements.types'; + +/** + * Service for inspecting connector elements (objects, activities, trigger events, + * field schemas) on UiPath Integration Service. + * + * The Elements API powers design-time tooling — every connector exposes a + * catalog of *objects* (resources like `contacts`, `messages`), *activities* + * (curated operations like `Send Email`), and *event objects* (trigger sources + * like `New Message`). Each can be inspected statically (connector-only) or + * scoped to a connection instance (which enriches the response with custom + * fields discovered from the live system). + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const elements = new Elements(sdk); + * const objects = await elements.getObjects('uipath-slack'); + * ``` + */ +export interface ElementsServiceModel { + /** + * List objects (resources) exposed by a connector. + * + * @param elementKey - Connector key (e.g. `uipath-slack`) + * @param options - Filtering options (type, subtype, hasEvents, hasBulk) + * @returns Promise resolving to an array of {@link ElementObject} + * @example + * ```typescript + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const elements = new Elements(sdk); + * + * const objects = await elements.getObjects('uipath-slack'); + * for (const obj of objects) { + * console.log(`${obj.name} — ${obj.displayName}`); + * } + * ``` + * + * @example + * ```typescript + * // Only objects that support events + * const eventCapable = await elements.getObjects('uipath-slack', { hasEvents: true }); + * ``` + */ + getObjects(elementKey: string, options?: ElementObjectsGetOptions): Promise; + + /** + * List curated activities exposed by a connector. + * + * @param elementKey - Connector key + * @param options - Optional `version` to pin to a specific connector schema + * @returns Promise resolving to an array of {@link ElementActivity} + * @example + * ```typescript + * const activities = await elements.getActivities('uipath-slack'); + * for (const activity of activities) { + * console.log(`${activity.name}: ${activity.operation} on ${activity.objectName}`); + * } + * ``` + */ + getActivities(elementKey: string, options?: ElementActivitiesGetOptions): Promise; + + /** + * Get metadata for a single connector object (field schema, supported methods, + * parameters). Connection-independent — returns the standard schema only. + * + * @param elementKey - Connector key + * @param objectName - Object name (e.g. `messages`) + * @param options - Optional `version`, `hydrateParameters`, `includeParentArray` + * @returns Promise resolving to an {@link ElementObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getObjectMetadata('uipath-slack', 'messages'); + * console.log(`Fields: ${Object.keys(meta.fields ?? {}).length}`); + * ``` + */ + getObjectMetadata( + elementKey: string, + objectName: string, + options?: ElementObjectMetadataGetOptions, + ): Promise; + + /** + * List event objects (trigger sources) for a connector's event operation. + * + * @param elementKey - Connector key + * @param operationName - Event operation name (e.g. `INDEX_COMPLETED`) + * @param options - Optional `version` + * @returns Promise resolving to an array of {@link ElementEventObject} + * @example + * ```typescript + * const events = await elements.getEventObjects('uipath-slack', 'NEW_MESSAGE'); + * ``` + */ + getEventObjects( + elementKey: string, + operationName: string, + options?: ElementEventObjectsGetOptions, + ): Promise; + + /** + * Get metadata for a single event object. + * + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param objectName - Event object name + * @param options - Optional `version`, `allFields`, `includeParentArray` + * @returns Promise resolving to an {@link ElementEventObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getEventObjectMetadata( + * 'uipath-slack', + * 'NEW_MESSAGE', + * 'channels', + * ); + * ``` + */ + getEventObjectMetadata( + elementKey: string, + operationName: string, + objectName: string, + options?: ElementEventObjectMetadataGetOptions, + ): Promise; + + /** + * List objects exposed by a connection instance (includes connector custom + * fields discovered from the live system). + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param options - Filtering options + * @returns Promise resolving to an array of {@link ElementObject} + * @example + * ```typescript + * const objects = await elements.getInstanceObjects('', 'uipath-slack'); + * ``` + */ + getInstanceObjects( + connectionId: string, + elementKey: string, + options?: ElementObjectsGetOptions, + ): Promise; + + /** + * Get instance-scoped metadata for a single object — includes connector + * custom fields discovered from the live system. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param objectName - Object name + * @param options - Optional `version`, `hydrateParameters`, `includeParentArray` + * @returns Promise resolving to an {@link ElementObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getInstanceObjectMetadata( + * '', + * 'uipath-salesforce', + * 'Account', + * ); + * ``` + */ + getInstanceObjectMetadata( + connectionId: string, + elementKey: string, + objectName: string, + options?: ElementObjectMetadataGetOptions, + ): Promise; + + /** + * List event objects for a connection instance. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param options - Optional `version` + * @returns Promise resolving to an array of {@link ElementEventObject} + * @example + * ```typescript + * const events = await elements.getInstanceEventObjects( + * '', + * 'uipath-slack', + * 'NEW_MESSAGE', + * ); + * ``` + */ + getInstanceEventObjects( + connectionId: string, + elementKey: string, + operationName: string, + options?: ElementEventObjectsGetOptions, + ): Promise; + + /** + * Get instance-scoped metadata for a single event object. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param objectName - Event object name + * @param options - Optional `version`, `allFields`, `includeParentArray` + * @returns Promise resolving to an {@link ElementEventObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getInstanceEventObjectMetadata( + * '', + * 'uipath-slack', + * 'NEW_MESSAGE', + * 'channels', + * ); + * ``` + */ + getInstanceEventObjectMetadata( + connectionId: string, + elementKey: string, + operationName: string, + objectName: string, + options?: ElementEventObjectMetadataGetOptions, + ): Promise; +} diff --git a/src/models/integration-service/elements.types.ts b/src/models/integration-service/elements.types.ts new file mode 100644 index 000000000..49ddff9ec --- /dev/null +++ b/src/models/integration-service/elements.types.ts @@ -0,0 +1,296 @@ +/** + * Integration Service — Elements (connector metadata) types. + * + * The Elements API returns deeply nested, connector-dependent shapes. We type + * the well-known fields explicitly and leave the open extension points loose + * (`Record`) so the SDK doesn't force a false-positive failure + * when a connector ships a new metadata field. + */ + +/** + * A parameter declared on a connector method (path, query, body, header, value). + */ +export interface ElementMethodParameter { + /** Parameter name (e.g. `folderKey`, `limit`). */ + name: string; + /** Where the parameter is sent. */ + type: string; + /** Parameter primitive type (`string`, `integer`, ...). */ + dataType?: string; + /** Whether the parameter is required. */ + required?: boolean; + /** Caller-facing display name. */ + displayName?: string; + /** Default value if any. */ + defaultValue?: unknown; + /** Free-form description. */ + description?: string; + /** Whether this parameter participates in curated UX. */ + curated?: boolean; + /** Cross-object reference (lookup hints). */ + reference?: Record; + /** Design-time UX hints. */ + design?: Record; + /** Sort order in UX panels. */ + sortOrder?: number; + /** Open-ended additional fields the API may ship per connector. */ + [key: string]: unknown; +} + +/** + * Describes a single HTTP method invocation on a connector object. + */ +export interface ElementMethodDefinition { + /** Logical operation name (e.g. `Create`, `List`). */ + operation?: string; + /** HTTP method. */ + method: string; + /** API path template. */ + path?: string; + /** Parameters consumed by the method. */ + parameters?: ElementMethodParameter[]; + /** OpenAPI operation ID. */ + operationId?: string; + /** Curated activity descriptor. */ + curated?: Record; + /** Friendly response label. */ + responseDisplayName?: string; + /** Design-time UX hints. */ + design?: Record; + /** Open-ended additional fields. */ + [key: string]: unknown; +} + +/** + * Connector object metadata block. The `method` map is keyed by HTTP verb. + */ +export interface ElementObjectMetadata { + /** Whether the object exposes connector-specific custom fields. */ + hasCustomFieldDiscovery?: boolean; + /** HTTP-verb-keyed map of methods supported on this object. */ + method?: Record; + /** Whether the object exposes search-friendly fields. */ + hasSearchables?: boolean; + /** Open-ended additional fields. */ + [key: string]: unknown; +} + +/** + * A connector object (resource). + */ +export interface ElementObject { + /** Object name (e.g. `contacts`). */ + name: string; + /** API path template. */ + path?: string; + /** Object type (e.g. `standard`, `custom`). */ + type?: string; + /** Object subtype. */ + subType?: string; + /** Owning connector key. */ + elementKey?: string; + /** Display name. */ + displayName?: string; + /** Connector custom-ness marker. */ + custom?: string; + /** Object metadata block. */ + metadata?: ElementObjectMetadata; + /** Whether the object is featured as a priority. */ + isPriority?: boolean; + /** Whether the object is hidden from UX. */ + isHidden?: boolean; +} + +/** + * A curated activity exposed by a connector. + */ +export interface ElementActivity { + /** Activity name. */ + name: string; + /** Display name. */ + displayName?: string; + /** Description. */ + description?: string; + /** Object the activity operates on. */ + objectName?: string; + /** HTTP method name. */ + methodName?: string; + /** Event operation (for triggers). */ + eventOperation?: string; + /** Logical operation (e.g. `List`, `Create`). */ + operation?: string; + /** Event delivery mode (e.g. `polling`, `webhooks`). */ + eventMode?: string; + /** Lifecycle stage (`GA`, `BETA`, ...). */ + lifecycleStage?: string; + /** Project types this activity is compatible with. */ + compatibleProjectTypes?: string[]; + /** Tag list. */ + tags?: string[]; + /** Subtype. */ + subType?: string; + /** Whether this activity is a trigger. */ + trigger?: boolean; + /** Whether this activity is a curated UX element. */ + curated?: boolean; + /** Trigger marker (legacy). */ + isTrigger?: boolean; + /** Open-ended additional fields. */ + [key: string]: unknown; +} + +/** + * Field descriptor on a metadata response — the value in the `fields` map, + * keyed by field name/path (e.g. `channel`, `attachment.image_url`). + * + * Shape is connector-dependent and very open-ended (type, sample value, HTTP + * method usage, lookup references, UI hints). We keep it as a free-form record. + */ +export type ElementField = Record; + +/** + * Object metadata response (returned by `getObjectMetadata` and `getInstanceObjectMetadata`). + */ +export interface ElementObjectMetadataResponse { + /** Object name. */ + name: string; + /** Path template. */ + path?: string; + /** Object type. */ + type?: string; + /** Object subtype. */ + subType?: string; + /** Owning connector key. */ + elementKey?: string; + /** Display name. */ + displayName?: string; + /** Connector custom-ness marker. */ + custom?: string; + /** Connector object metadata. */ + metadata?: ElementObjectMetadata; + /** Field schema, keyed by field name/path. */ + fields?: Record; + /** Whether the object is hidden from UX. */ + isHidden?: boolean; + /** Whether the object is featured as a priority. */ + isPriority?: boolean; +} + +/** + * An event-source object (trigger root). + */ +export interface ElementEventObject { + /** Event object name. */ + name: string; + /** Event delivery mode (`polling`, `webhooks`). */ + eventMode?: string; + /** Whether the event ID field is hidden in UX. */ + hideEventIDField?: boolean; + /** Whether the event is debug-disabled. */ + debugDisabled?: boolean; + /** Friendly display name. */ + displayName?: string; + /** Whether the webhook URL is shown in the UX. */ + isWebhookUrlVisible?: boolean; + /** Whether the event uses Bring-Your-Own-Auth. */ + byoaConnection?: boolean; + /** Whether the left-operand filter builder is static. */ + hasStaticLeftOperandFilterBuilder?: boolean; + /** Whether the event is hidden from UX. */ + isHidden?: boolean; + /** Auth types this event supports. */ + supportedAuths?: string[]; + /** Event-source parameters. */ + parameters?: ElementMethodParameter[]; + /** Logical operation. */ + operation?: string; + /** Bound object name (for events that resolve dynamically). */ + objectName?: string; + /** Whether the object name is dynamic. */ + isDynamicObjectName?: boolean; + /** Description. */ + description?: string; + /** Open-ended additional fields. */ + [key: string]: unknown; +} + +/** + * Event-object metadata response. + */ +export interface ElementEventObjectMetadataResponse { + /** Event object name. */ + name: string; + /** Path template. */ + path?: string; + /** Object type. */ + type?: string; + /** Object subtype. */ + subType?: string; + /** Owning connector key. */ + elementKey?: string; + /** Display name. */ + displayName?: string; + /** Connector custom-ness marker. */ + custom?: string; + /** Event delivery mode. */ + eventMode?: string; + /** Field schema, keyed by field name/path. */ + fields?: Record; +} + +/** + * Options for {@link ElementsServiceModel.getObjects}. + */ +export interface ElementObjectsGetOptions { + /** Filter by object type (e.g. `standard`, `custom`). */ + type?: string; + /** Filter by object subtype. */ + subType?: string; + /** Limit to objects that support events. */ + hasEvents?: boolean; + /** Limit to objects that support bulk operations. */ + hasBulk?: boolean; +} + +/** + * Options for {@link ElementsServiceModel.getActivities}. + */ +export interface ElementActivitiesGetOptions { + /** Connector schema version (defaults to the latest published). */ + version?: string; +} + +/** + * Options for {@link ElementsServiceModel.getObjectMetadata} + * and {@link ElementsServiceModel.getInstanceObjectMetadata}. + */ +export interface ElementObjectMetadataGetOptions { + /** Connector schema version (defaults to the latest published). */ + version?: string; + /** Hydrate parameters with values discovered from the connection. */ + hydrateParameters?: boolean; + /** Include parent-array references in nested objects. */ + includeParentArray?: boolean; +} + +/** + * Options for {@link ElementsServiceModel.getEventObjects} + * and {@link ElementsServiceModel.getInstanceEventObjects}. + */ +export interface ElementEventObjectsGetOptions { + /** Connector schema version (defaults to the latest published). */ + version?: string; +} + +/** + * Options for {@link ElementsServiceModel.getEventObjectMetadata} + * and {@link ElementsServiceModel.getInstanceEventObjectMetadata}. + */ +export interface ElementEventObjectMetadataGetOptions { + /** Connector schema version (defaults to the latest published). */ + version?: string; + /** Include all fields, not just curated. */ + allFields?: boolean; + /** Include parent-array references in nested objects. */ + includeParentArray?: boolean; +} diff --git a/src/models/integration-service/execution.types.ts b/src/models/integration-service/execution.types.ts new file mode 100644 index 000000000..f478f1b07 --- /dev/null +++ b/src/models/integration-service/execution.types.ts @@ -0,0 +1,42 @@ +/** + * Integration Service — Execution passthrough types. + */ + +/** + * HTTP method for an execute call. + */ +export type ExecuteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +/** + * Result envelope returned by {@link execute}. + * + * Unlike most SDK methods this *does not* throw on non-2xx responses — the + * caller inspects `ok` / `status` and `body` to handle connector-specific + * errors. This is required because the underlying Integration Service API + * proxies arbitrary third-party HTTP calls, and the body carries vendor + * error details that callers need to surface. + */ +export interface ExecuteResult { + /** True for HTTP 2xx responses. */ + ok: boolean; + /** HTTP status code from the underlying call. */ + status: number; + /** HTTP status text from the underlying call. */ + statusText: string; + /** Parsed JSON body when the response is JSON, raw text otherwise. */ + body: unknown; + /** Response headers as a flat record. */ + headers: Record; +} + +/** + * Options accepted by {@link execute}. + */ +export interface ExecuteOptions { + /** Body to send for POST/PUT/PATCH. Serialized as JSON. */ + body?: unknown; + /** Query string parameters. */ + queryParams?: Record; + /** Folder key (GUID) to scope the call via `x-uipath-folderkey`. */ + folderKey?: string; +} diff --git a/src/models/integration-service/index.ts b/src/models/integration-service/index.ts index a18531f9c..cc7ff265d 100644 --- a/src/models/integration-service/index.ts +++ b/src/models/integration-service/index.ts @@ -8,3 +8,5 @@ export * from './connectors.types'; export * from './connectors.models'; export * from './connections.types'; export * from './connections.models'; +export * from './elements.types'; +export * from './elements.models'; diff --git a/src/services/integration-service/elements/elements.ts b/src/services/integration-service/elements/elements.ts new file mode 100644 index 000000000..fd94e6606 --- /dev/null +++ b/src/services/integration-service/elements/elements.ts @@ -0,0 +1,329 @@ +import { BaseService } from '../../base'; +import { track } from '../../../core/telemetry'; +import { ValidationError } from '../../../core/errors'; +import { ELEMENT_ENDPOINTS } from '../../../utils/constants/endpoints'; +import { QueryParams } from '../../../models/common/request-spec'; +import { + ElementObject, + ElementActivity, + ElementObjectMetadataResponse, + ElementEventObject, + ElementEventObjectMetadataResponse, + ElementObjectsGetOptions, + ElementActivitiesGetOptions, + ElementObjectMetadataGetOptions, + ElementEventObjectsGetOptions, + ElementEventObjectMetadataGetOptions, +} from '../../../models/integration-service/elements.types'; +import { ElementsServiceModel } from '../../../models/integration-service/elements.models'; + +function requireArg(value: string, name: string, method: string): void { + if (!value) { + throw new ValidationError({ message: `${name} is required for ${method}` }); + } +} + +/** + * Service for inspecting connector elements (objects, activities, trigger events, + * field schemas) on UiPath Integration Service. + * + * The Elements API powers design-time tooling — every connector exposes a + * catalog of *objects* (resources like `contacts`, `messages`), *activities* + * (curated operations like `Send Email`), and *event objects* (trigger sources + * like `New Message`). Each can be inspected statically (connector-only) or + * scoped to a connection instance (which enriches the response with custom + * fields discovered from the live system). + * + * ### Usage + * + * Prerequisites: Initialize the SDK first - see [Getting Started](/uipath-typescript/getting-started/#import-initialize) + * + * ```typescript + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const elements = new Elements(sdk); + * const objects = await elements.getObjects('uipath-slack'); + * ``` + */ +export class ElementsService extends BaseService implements ElementsServiceModel { + /** + * List objects (resources) exposed by a connector. + * + * @param elementKey - Connector key (e.g. `uipath-slack`) + * @param options - Filtering options (type, subtype, hasEvents, hasBulk) + * @returns Promise resolving to an array of {@link ElementObject} + * @example + * ```typescript + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const elements = new Elements(sdk); + * + * const objects = await elements.getObjects('uipath-slack'); + * for (const obj of objects) { + * console.log(`${obj.name} — ${obj.displayName}`); + * } + * ``` + * + * @example + * ```typescript + * // Only objects that support events + * const eventCapable = await elements.getObjects('uipath-slack', { hasEvents: true }); + * ``` + */ + @track('Elements.GetObjects') + async getObjects(elementKey: string, options?: ElementObjectsGetOptions): Promise { + requireArg(elementKey, 'elementKey', 'getObjects'); + const response = await this.get(ELEMENT_ENDPOINTS.OBJECTS.LIST(elementKey), { + params: options as QueryParams | undefined, + }); + return response.data ?? []; + } + + /** + * List curated activities exposed by a connector. + * + * @param elementKey - Connector key + * @param options - Optional `version` to pin to a specific connector schema + * @returns Promise resolving to an array of {@link ElementActivity} + * @example + * ```typescript + * const activities = await elements.getActivities('uipath-slack'); + * for (const activity of activities) { + * console.log(`${activity.name}: ${activity.operation} on ${activity.objectName}`); + * } + * ``` + */ + @track('Elements.GetActivities') + async getActivities(elementKey: string, options?: ElementActivitiesGetOptions): Promise { + requireArg(elementKey, 'elementKey', 'getActivities'); + const response = await this.get(ELEMENT_ENDPOINTS.ACTIVITIES.LIST(elementKey), { + params: options as QueryParams | undefined, + }); + return response.data ?? []; + } + + /** + * Get metadata for a single connector object (field schema, supported methods, + * parameters). Connection-independent — returns the standard schema only. + * + * @param elementKey - Connector key + * @param objectName - Object name (e.g. `messages`) + * @param options - Optional `version`, `hydrateParameters`, `includeParentArray` + * @returns Promise resolving to an {@link ElementObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getObjectMetadata('uipath-slack', 'messages'); + * console.log(`Fields: ${Object.keys(meta.fields ?? {}).length}`); + * ``` + */ + @track('Elements.GetObjectMetadata') + async getObjectMetadata( + elementKey: string, + objectName: string, + options?: ElementObjectMetadataGetOptions, + ): Promise { + requireArg(elementKey, 'elementKey', 'getObjectMetadata'); + requireArg(objectName, 'objectName', 'getObjectMetadata'); + const response = await this.get( + ELEMENT_ENDPOINTS.OBJECTS.METADATA(elementKey, objectName), + { params: options as QueryParams | undefined }, + ); + return response.data; + } + + /** + * List event objects (trigger sources) for a connector's event operation. + * + * @param elementKey - Connector key + * @param operationName - Event operation name (e.g. `INDEX_COMPLETED`) + * @param options - Optional `version` + * @returns Promise resolving to an array of {@link ElementEventObject} + * @example + * ```typescript + * const events = await elements.getEventObjects('uipath-slack', 'NEW_MESSAGE'); + * ``` + */ + @track('Elements.GetEventObjects') + async getEventObjects( + elementKey: string, + operationName: string, + options?: ElementEventObjectsGetOptions, + ): Promise { + requireArg(elementKey, 'elementKey', 'getEventObjects'); + requireArg(operationName, 'operationName', 'getEventObjects'); + const response = await this.get( + ELEMENT_ENDPOINTS.EVENTS.OBJECTS(elementKey, operationName), + { params: options as QueryParams | undefined }, + ); + return response.data ?? []; + } + + /** + * Get metadata for a single event object. + * + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param objectName - Event object name + * @param options - Optional `version`, `allFields`, `includeParentArray` + * @returns Promise resolving to an {@link ElementEventObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getEventObjectMetadata( + * 'uipath-slack', + * 'NEW_MESSAGE', + * 'channels', + * ); + * ``` + */ + @track('Elements.GetEventObjectMetadata') + async getEventObjectMetadata( + elementKey: string, + operationName: string, + objectName: string, + options?: ElementEventObjectMetadataGetOptions, + ): Promise { + requireArg(elementKey, 'elementKey', 'getEventObjectMetadata'); + requireArg(operationName, 'operationName', 'getEventObjectMetadata'); + requireArg(objectName, 'objectName', 'getEventObjectMetadata'); + const response = await this.get( + ELEMENT_ENDPOINTS.EVENTS.METADATA(elementKey, operationName, objectName), + { params: options as QueryParams | undefined }, + ); + return response.data; + } + + /** + * List objects exposed by a connection instance (includes connector custom + * fields discovered from the live system). + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param options - Filtering options + * @returns Promise resolving to an array of {@link ElementObject} + * @example + * ```typescript + * const objects = await elements.getInstanceObjects('', 'uipath-slack'); + * ``` + */ + @track('Elements.GetInstanceObjects') + async getInstanceObjects( + connectionId: string, + elementKey: string, + options?: ElementObjectsGetOptions, + ): Promise { + requireArg(connectionId, 'connectionId', 'getInstanceObjects'); + requireArg(elementKey, 'elementKey', 'getInstanceObjects'); + const response = await this.get( + ELEMENT_ENDPOINTS.INSTANCE.OBJECTS.LIST(connectionId, elementKey), + { params: options as QueryParams | undefined }, + ); + return response.data ?? []; + } + + /** + * Get instance-scoped metadata for a single object — includes connector + * custom fields discovered from the live system. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param objectName - Object name + * @param options - Optional `version`, `hydrateParameters`, `includeParentArray` + * @returns Promise resolving to an {@link ElementObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getInstanceObjectMetadata( + * '', + * 'uipath-salesforce', + * 'Account', + * ); + * ``` + */ + @track('Elements.GetInstanceObjectMetadata') + async getInstanceObjectMetadata( + connectionId: string, + elementKey: string, + objectName: string, + options?: ElementObjectMetadataGetOptions, + ): Promise { + requireArg(connectionId, 'connectionId', 'getInstanceObjectMetadata'); + requireArg(elementKey, 'elementKey', 'getInstanceObjectMetadata'); + requireArg(objectName, 'objectName', 'getInstanceObjectMetadata'); + const response = await this.get( + ELEMENT_ENDPOINTS.INSTANCE.OBJECTS.METADATA(connectionId, elementKey, objectName), + { params: options as QueryParams | undefined }, + ); + return response.data; + } + + /** + * List event objects for a connection instance. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param options - Optional `version` + * @returns Promise resolving to an array of {@link ElementEventObject} + * @example + * ```typescript + * const events = await elements.getInstanceEventObjects( + * '', + * 'uipath-slack', + * 'NEW_MESSAGE', + * ); + * ``` + */ + @track('Elements.GetInstanceEventObjects') + async getInstanceEventObjects( + connectionId: string, + elementKey: string, + operationName: string, + options?: ElementEventObjectsGetOptions, + ): Promise { + requireArg(connectionId, 'connectionId', 'getInstanceEventObjects'); + requireArg(elementKey, 'elementKey', 'getInstanceEventObjects'); + requireArg(operationName, 'operationName', 'getInstanceEventObjects'); + const response = await this.get( + ELEMENT_ENDPOINTS.INSTANCE.EVENTS.OBJECTS(connectionId, elementKey, operationName), + { params: options as QueryParams | undefined }, + ); + return response.data ?? []; + } + + /** + * Get instance-scoped metadata for a single event object. + * + * @param connectionId - Connection GUID + * @param elementKey - Connector key + * @param operationName - Event operation name + * @param objectName - Event object name + * @param options - Optional `version`, `allFields`, `includeParentArray` + * @returns Promise resolving to an {@link ElementEventObjectMetadataResponse} + * @example + * ```typescript + * const meta = await elements.getInstanceEventObjectMetadata( + * '', + * 'uipath-slack', + * 'NEW_MESSAGE', + * 'channels', + * ); + * ``` + */ + @track('Elements.GetInstanceEventObjectMetadata') + async getInstanceEventObjectMetadata( + connectionId: string, + elementKey: string, + operationName: string, + objectName: string, + options?: ElementEventObjectMetadataGetOptions, + ): Promise { + requireArg(connectionId, 'connectionId', 'getInstanceEventObjectMetadata'); + requireArg(elementKey, 'elementKey', 'getInstanceEventObjectMetadata'); + requireArg(operationName, 'operationName', 'getInstanceEventObjectMetadata'); + requireArg(objectName, 'objectName', 'getInstanceEventObjectMetadata'); + const response = await this.get( + ELEMENT_ENDPOINTS.INSTANCE.EVENTS.METADATA(connectionId, elementKey, operationName, objectName), + { params: options as QueryParams | undefined }, + ); + return response.data; + } +} diff --git a/src/services/integration-service/elements/index.ts b/src/services/integration-service/elements/index.ts new file mode 100644 index 000000000..b0217ef99 --- /dev/null +++ b/src/services/integration-service/elements/index.ts @@ -0,0 +1,22 @@ +/** + * Integration Service — Elements module. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Elements } from '@uipath/uipath-typescript/is-elements'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const elements = new Elements(sdk); + * const objects = await elements.getObjects('uipath-slack'); + * ``` + * + * @module + */ + +export { ElementsService as Elements, ElementsService } from './elements'; + +export * from '../../../models/integration-service/elements.types'; +export * from '../../../models/integration-service/elements.models'; diff --git a/src/services/integration-service/execution/execution.ts b/src/services/integration-service/execution/execution.ts new file mode 100644 index 000000000..d6bd67937 --- /dev/null +++ b/src/services/integration-service/execution/execution.ts @@ -0,0 +1,154 @@ +import { track } from '../../../core/telemetry'; +import { ValidationError } from '../../../core/errors'; +import { SDKInternalsRegistry } from '../../../core/internals'; +import { ELEMENT_ENDPOINTS } from '../../../utils/constants/endpoints'; +import { FOLDER_KEY, CONTENT_TYPES } from '../../../utils/constants/headers'; +import type { IUiPath } from '../../../core/types'; +import { + ExecuteMethod, + ExecuteOptions, + ExecuteResult, +} from '../../../models/integration-service/execution.types'; + +/** + * Internal class so we can decorate `execute` with `@track`. Method decorators + * cannot be applied to free functions directly. + * + * @internal + */ +class Execution { + constructor(private readonly instance: IUiPath) {} + + @track('Execution.Execute') + async execute( + connectionId: string, + objectName: string, + method: ExecuteMethod, + options: ExecuteOptions, + ): Promise { + if (!connectionId) { + throw new ValidationError({ message: 'connectionId is required for execute' }); + } + if (!objectName) { + throw new ValidationError({ message: 'objectName is required for execute' }); + } + + const { config, tokenManager } = SDKInternalsRegistry.get(this.instance); + const token = await tokenManager.getValidToken(); + + const relativePath = ELEMENT_ENDPOINTS.INSTANCE.EXECUTE(connectionId, objectName); + const baseSegment = `${config.orgName}/${config.tenantName}/`; + const url = new URL(baseSegment + relativePath, config.baseUrl).toString(); + + let fullUrl = url; + if (options.queryParams && Object.keys(options.queryParams).length > 0) { + const qs = new URLSearchParams(options.queryParams).toString(); + const sep = url.includes('?') ? '&' : '?'; + fullUrl = `${url}${sep}${qs}`; + } + + const headers: Record = { + Authorization: `Bearer ${token}`, + }; + if (options.folderKey) { + headers[FOLDER_KEY] = options.folderKey; + } + + const hasBody = options.body !== undefined && ['POST', 'PUT', 'PATCH'].includes(method); + if (hasBody) { + headers['Content-Type'] = CONTENT_TYPES.JSON; + } + + const response = await fetch(fullUrl, { + method, + headers, + body: hasBody ? JSON.stringify(options.body) : undefined, + }); + + const text = await response.text(); + let parsed: unknown = text; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + } + + const flatHeaders: Record = {}; + response.headers.forEach((value, key) => { + flatHeaders[key] = value; + }); + + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: parsed, + headers: flatHeaders, + }; + } +} + +/** + * Execute an arbitrary HTTP operation against a connection instance through + * the Integration Service passthrough endpoint. + * + * Targets `elements_/v3/element/instances/{connectionId}/{objectName}`. + * Use this when you need to call a connector's runtime API without going + * through a curated SDK method. + * + * Unlike standard SDK methods, **this does not throw on non-2xx responses**. + * The full HTTP envelope (`ok`, `status`, `body`, `headers`) is returned so + * callers can surface connector-specific error bodies. + * + * @param uipath - UiPath SDK instance providing authentication and configuration + * @param connectionId - Connection GUID + * @param objectName - Connector object name (e.g. `tickets`, `messages`) + * @param method - HTTP method (defaults to `GET`) + * @param options - Body, query params, and folder scoping + * @returns Promise resolving to an {@link ExecuteResult} + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { execute } from '@uipath/uipath-typescript/is-execution'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * // GET — list records + * const result = await execute(sdk, '', 'tickets', 'GET'); + * if (result.ok) { + * console.log(result.body); + * } else { + * console.error(`Connector returned ${result.status}: ${JSON.stringify(result.body)}`); + * } + * ``` + * + * @example + * ```typescript + * // POST — create a record + * const result = await execute(sdk, '', 'tickets', 'POST', { + * body: { subject: 'New ticket', priority: 'high' }, + * }); + * ``` + * + * @example + * ```typescript + * // GET with query params and folder scoping + * const result = await execute(sdk, '', 'tickets', 'GET', { + * queryParams: { limit: '10', status: 'open' }, + * folderKey: '', + * }); + * ``` + */ +export async function execute( + uipath: IUiPath, + connectionId: string, + objectName: string, + method: ExecuteMethod = 'GET', + options: ExecuteOptions = {}, +): Promise { + return new Execution(uipath).execute(connectionId, objectName, method, options); +} diff --git a/src/services/integration-service/execution/index.ts b/src/services/integration-service/execution/index.ts new file mode 100644 index 000000000..18e34abc1 --- /dev/null +++ b/src/services/integration-service/execution/index.ts @@ -0,0 +1,19 @@ +/** + * Integration Service — Execution passthrough module. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { execute } from '@uipath/uipath-typescript/is-execution'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const result = await execute(sdk, '', 'tickets', 'GET'); + * ``` + * + * @module + */ + +export { execute } from './execution'; +export * from '../../../models/integration-service/execution.types'; diff --git a/src/utils/constants/endpoints/base.ts b/src/utils/constants/endpoints/base.ts index a6977204f..199133a11 100644 --- a/src/utils/constants/endpoints/base.ts +++ b/src/utils/constants/endpoints/base.ts @@ -10,3 +10,4 @@ export const AUTOPILOT_BASE = 'autopilotforeveryone_'; export const LLMOPS_BASE = 'llmopstenant_'; export const INSIGHTS_RTM_BASE = 'insightsrtm_'; export const CONNECTIONS_BASE = 'connections_'; +export const ELEMENTS_BASE = 'elements_/v3/element'; diff --git a/src/utils/constants/endpoints/integration-service.ts b/src/utils/constants/endpoints/integration-service.ts index f3596b9c2..0d09035f0 100644 --- a/src/utils/constants/endpoints/integration-service.ts +++ b/src/utils/constants/endpoints/integration-service.ts @@ -1,10 +1,12 @@ /** * Integration Service Endpoints * - * `connections_` domain — connectors and connections (CONNECTOR_ENDPOINTS, CONNECTION_ENDPOINTS). + * Two service domains: + * - `connections_` — connectors and connections (CONNECTOR_ENDPOINTS, CONNECTION_ENDPOINTS) + * - `elements_/v3/element` — connector elements and metadata (ELEMENT_ENDPOINTS) */ -import { CONNECTIONS_BASE } from './base'; +import { CONNECTIONS_BASE, ELEMENTS_BASE } from './base'; export const CONNECTOR_ENDPOINTS = { GET_ALL: `${CONNECTIONS_BASE}/api/v1/Connectors`, @@ -19,3 +21,46 @@ export const CONNECTION_ENDPOINTS = { PING: (connectionId: string) => `${CONNECTIONS_BASE}/api/v1/Connections/${encodeURIComponent(connectionId)}/ping`, REAUTHENTICATE: (connectionId: string) => `${CONNECTIONS_BASE}/api/v1/Connections/${encodeURIComponent(connectionId)}/auth`, } as const; + +export const ELEMENT_ENDPOINTS = { + OBJECTS: { + /** List objects (resources) exposed by a connector. */ + LIST: (elementKey: string) => `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/objects`, + /** Get metadata for a single object. */ + METADATA: (elementKey: string, objectName: string) => + `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/objects/${encodeURIComponent(objectName)}/metadata`, + }, + ACTIVITIES: { + /** List curated activities exposed by a connector. */ + LIST: (elementKey: string) => `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/activities`, + }, + EVENTS: { + /** List event objects for a connector's event operation (trigger). */ + OBJECTS: (elementKey: string, operationName: string) => + `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects`, + /** Get metadata for a single event object. */ + METADATA: (elementKey: string, operationName: string, objectName: string) => + `${ELEMENTS_BASE}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects/${encodeURIComponent(objectName)}/metadata`, + }, + INSTANCE: { + OBJECTS: { + /** List objects for a connection instance (includes custom fields). */ + LIST: (connectionId: string, elementKey: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/objects`, + /** Get instance-scoped metadata for a single object. */ + METADATA: (connectionId: string, elementKey: string, objectName: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/objects/${encodeURIComponent(objectName)}/metadata`, + }, + EVENTS: { + /** List event objects for a connection instance. */ + OBJECTS: (connectionId: string, elementKey: string, operationName: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects`, + /** Get instance-scoped metadata for a single event object. */ + METADATA: (connectionId: string, elementKey: string, operationName: string, objectName: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/elements/${encodeURIComponent(elementKey)}/events/operations/${encodeURIComponent(operationName)}/objects/${encodeURIComponent(objectName)}/metadata`, + }, + /** Generic HTTP passthrough endpoint for executing a request against a connection instance. */ + EXECUTE: (connectionId: string, objectName: string) => + `${ELEMENTS_BASE}/instances/${encodeURIComponent(connectionId)}/${encodeURIComponent(objectName)}`, + }, +} as const; diff --git a/tests/unit/services/integration-service/elements.test.ts b/tests/unit/services/integration-service/elements.test.ts new file mode 100644 index 000000000..d784d1e30 --- /dev/null +++ b/tests/unit/services/integration-service/elements.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ElementsService } from '../../../../src/services/integration-service/elements/elements'; +import { ELEMENT_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { ValidationError } from '../../../../src/core/errors'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { IS_TEST_CONSTANTS, createMockError, TEST_CONSTANTS } from '../../../utils/mocks'; + +vi.mock('../../../../src/core/http/api-client'); + +const EVENT_OPERATION = 'INDEX_COMPLETED'; +const OBJECT_NAME = 'indexes'; + +describe('ElementsService', () => { + let service: ElementsService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + service = new ElementsService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('static (connection-independent)', () => { + it('getObjects returns array and forwards options', async () => { + mockApiClient.get.mockResolvedValue([{ name: 'foo' }]); + const result = await service.getObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY, { hasEvents: true }); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.OBJECTS.LIST(IS_TEST_CONSTANTS.CONNECTOR_KEY), + { params: { hasEvents: true } }, + ); + expect(result).toEqual([{ name: 'foo' }]); + }); + + it('getActivities returns array', async () => { + mockApiClient.get.mockResolvedValue([{ name: 'send' }]); + const result = await service.getActivities(IS_TEST_CONSTANTS.CONNECTOR_KEY); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.ACTIVITIES.LIST(IS_TEST_CONSTANTS.CONNECTOR_KEY), + { params: undefined }, + ); + expect(result).toEqual([{ name: 'send' }]); + }); + + it('getObjectMetadata returns single metadata object', async () => { + const mockMeta = { name: OBJECT_NAME, fields: { id: { name: 'id' } } }; + mockApiClient.get.mockResolvedValue(mockMeta); + const result = await service.getObjectMetadata(IS_TEST_CONSTANTS.CONNECTOR_KEY, OBJECT_NAME, { + version: '1.0', + includeParentArray: true, + }); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.OBJECTS.METADATA(IS_TEST_CONSTANTS.CONNECTOR_KEY, OBJECT_NAME), + { params: { version: '1.0', includeParentArray: true } }, + ); + expect(result).toBe(mockMeta); + }); + + it('getEventObjects returns array', async () => { + mockApiClient.get.mockResolvedValue([{ name: OBJECT_NAME }]); + await service.getEventObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY, EVENT_OPERATION); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.EVENTS.OBJECTS(IS_TEST_CONSTANTS.CONNECTOR_KEY, EVENT_OPERATION), + { params: undefined }, + ); + }); + + it('getEventObjectMetadata returns single object', async () => { + const mockMeta = { name: OBJECT_NAME }; + mockApiClient.get.mockResolvedValue(mockMeta); + await service.getEventObjectMetadata( + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + OBJECT_NAME, + { allFields: true }, + ); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.EVENTS.METADATA(IS_TEST_CONSTANTS.CONNECTOR_KEY, EVENT_OPERATION, OBJECT_NAME), + { params: { allFields: true } }, + ); + }); + }); + + describe('instance (connection-scoped)', () => { + it('getInstanceObjects returns array', async () => { + mockApiClient.get.mockResolvedValue([{ name: 'foo' }]); + await service.getInstanceObjects(IS_TEST_CONSTANTS.CONNECTION_ID, IS_TEST_CONSTANTS.CONNECTOR_KEY); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.INSTANCE.OBJECTS.LIST( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + ), + { params: undefined }, + ); + }); + + it('getInstanceObjectMetadata returns metadata', async () => { + mockApiClient.get.mockResolvedValue({ name: OBJECT_NAME }); + await service.getInstanceObjectMetadata( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + OBJECT_NAME, + ); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.INSTANCE.OBJECTS.METADATA( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + OBJECT_NAME, + ), + { params: undefined }, + ); + }); + + it('getInstanceEventObjects returns array', async () => { + mockApiClient.get.mockResolvedValue([{ name: OBJECT_NAME }]); + await service.getInstanceEventObjects( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + ); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.INSTANCE.EVENTS.OBJECTS( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + ), + { params: undefined }, + ); + }); + + it('getInstanceEventObjectMetadata returns metadata', async () => { + mockApiClient.get.mockResolvedValue({ name: OBJECT_NAME }); + await service.getInstanceEventObjectMetadata( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + OBJECT_NAME, + ); + expect(mockApiClient.get).toHaveBeenCalledWith( + ELEMENT_ENDPOINTS.INSTANCE.EVENTS.METADATA( + IS_TEST_CONSTANTS.CONNECTION_ID, + IS_TEST_CONSTANTS.CONNECTOR_KEY, + EVENT_OPERATION, + OBJECT_NAME, + ), + { params: undefined }, + ); + }); + }); + + describe('validation', () => { + it('throws ValidationError when elementKey is missing', async () => { + await expect(service.getObjects('')).rejects.toThrow(ValidationError); + await expect(service.getActivities('')).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when objectName is missing', async () => { + await expect( + service.getObjectMetadata(IS_TEST_CONSTANTS.CONNECTOR_KEY, ''), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when connectionId is missing on instance reads', async () => { + await expect( + service.getInstanceObjects('', IS_TEST_CONSTANTS.CONNECTOR_KEY), + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when operationName is missing on event reads', async () => { + await expect( + service.getEventObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY, ''), + ).rejects.toThrow(ValidationError); + }); + }); + + describe('error propagation', () => { + it('propagates API errors from getObjects', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + await expect(service.getObjects(IS_TEST_CONSTANTS.CONNECTOR_KEY)).rejects.toThrow( + TEST_CONSTANTS.ERROR_MESSAGE, + ); + }); + }); +}); diff --git a/tests/unit/services/integration-service/execution.test.ts b/tests/unit/services/integration-service/execution.test.ts new file mode 100644 index 000000000..9cf9a892d --- /dev/null +++ b/tests/unit/services/integration-service/execution.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { execute } from '../../../../src/services/integration-service/execution/execution'; +import { ValidationError } from '../../../../src/core/errors'; +import { createServiceTestDependencies } from '../../../utils/setup'; +import { IS_TEST_CONSTANTS } from '../../../utils/mocks'; +import { FOLDER_KEY } from '../../../../src/utils/constants/headers'; + +const OBJECT_NAME = 'tickets'; + +interface MockableTokenManager { + getValidToken?: () => Promise; +} + +function buildDeps() { + const deps = createServiceTestDependencies(); + (deps.tokenManager as unknown as MockableTokenManager).getValidToken = vi + .fn() + .mockResolvedValue('mock-access-token'); + return deps; +} + +const buildResponse = (init: { + status?: number; + statusText?: string; + body?: string; + headers?: Record; +}) => { + const status = init.status ?? 200; + return new Response(init.body ?? '', { + status, + statusText: init.statusText ?? 'OK', + headers: init.headers ?? { 'content-type': 'application/json' }, + }); +}; + +describe('execute', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it('executes a GET against the connection passthrough endpoint', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: JSON.stringify([{ id: 1 }]) })); + + const result = await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toContain(`/elements_/v3/element/instances/${IS_TEST_CONSTANTS.CONNECTION_ID}/${OBJECT_NAME}`); + expect(init.method).toBe('GET'); + expect(init.headers).toMatchObject({ Authorization: expect.stringMatching(/^Bearer /) }); + expect(init.body).toBeUndefined(); + expect(result.ok).toBe(true); + expect(result.status).toBe(200); + expect(result.body).toEqual([{ id: 1 }]); + }); + + it('serializes JSON body for POST', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: JSON.stringify({ id: 42 }) })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'POST', { + body: { subject: 'New' }, + }); + + const [, init] = fetchSpy.mock.calls[0]; + expect(init.method).toBe('POST'); + expect(init.headers['Content-Type']).toBe('application/json'); + expect(init.body).toBe('{"subject":"New"}'); + }); + + it('appends query params to the URL', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: '[]' })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'GET', { + queryParams: { limit: '10', status: 'open' }, + }); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain('limit=10'); + expect(url).toContain('status=open'); + }); + + it('sends folder header when folderKey is provided', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue(buildResponse({ body: '[]' })); + + await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME, 'GET', { + folderKey: IS_TEST_CONSTANTS.FOLDER_KEY, + }); + + const [, init] = fetchSpy.mock.calls[0]; + expect(init.headers[FOLDER_KEY]).toBe(IS_TEST_CONSTANTS.FOLDER_KEY); + }); + + it('returns full envelope on non-2xx without throwing', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue( + buildResponse({ + status: 400, + statusText: 'Bad Request', + body: JSON.stringify({ error: 'invalid' }), + }), + ); + + const result = await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME); + + expect(result.ok).toBe(false); + expect(result.status).toBe(400); + expect(result.statusText).toBe('Bad Request'); + expect(result.body).toEqual({ error: 'invalid' }); + }); + + it('returns raw text when response body is not JSON', async () => { + const { instance } = buildDeps(); + fetchSpy.mockResolvedValue( + buildResponse({ body: 'plain text', headers: { 'content-type': 'text/plain' } }), + ); + + const result = await execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, OBJECT_NAME); + + expect(result.body).toBe('plain text'); + }); + + it('throws ValidationError when connectionId is missing', async () => { + const { instance } = buildDeps(); + await expect( + execute(instance, '', OBJECT_NAME), + ).rejects.toThrow(ValidationError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when objectName is missing', async () => { + const { instance } = buildDeps(); + await expect( + execute(instance, IS_TEST_CONSTANTS.CONNECTION_ID, ''), + ).rejects.toThrow(ValidationError); + }); +});