diff --git a/core/src/common.ts b/core/src/common.ts index 23f628165..64b302c40 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -309,6 +309,7 @@ export * from './artifacts/base_artifact_service.js'; export * from './features/feature_registry.js'; export * from './memory/base_memory_service.js'; export * from './sessions/base_session_service.js'; +export type {EntityOperations} from './tools/application_integration_tool/clients/integration_client.js'; export * from './tools/base_tool.js'; export {OpenApiSpecParser} from './tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.js'; export type { diff --git a/core/src/tools/application_integration_tool/clients/api_request.ts b/core/src/tools/application_integration_tool/clients/api_request.ts new file mode 100644 index 000000000..43b2bdf4c --- /dev/null +++ b/core/src/tools/application_integration_tool/clients/api_request.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AnyAuthClient, GoogleAuth, JWT} from 'google-auth-library'; +import {toMessage} from '../../../utils/error_utils.js'; +import {parseServiceAccountCredential} from '../../../utils/service_account_utils.js'; + +/** Upper bound on how long a single Google API request may take. */ +export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; + +const CLOUD_PLATFORM_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', +]; + +const MISSING_CREDENTIALS_MESSAGE = + 'Please provide a service account that has the required permissions to' + + ' access the connection.'; + +/** + * Supplies OAuth2 access tokens for Google API calls, either from an explicit + * service-account key file or from Application Default Credentials. + * + * The underlying `google-auth-library` client caches and refreshes tokens on + * its own, so a token is requested per call rather than cached here. + */ +export class AccessTokenProvider { + private readonly auth: GoogleAuth; + private readonly hasExplicitServiceAccount: boolean; + + constructor(serviceAccountJson?: string) { + this.hasExplicitServiceAccount = Boolean(serviceAccountJson); + this.auth = serviceAccountJson + ? new GoogleAuth({ + authClient: createServiceAccountClient(serviceAccountJson), + scopes: CLOUD_PLATFORM_SCOPES, + }) + : new GoogleAuth({scopes: CLOUD_PLATFORM_SCOPES}); + } + + /** + * Resolves an access token for the cloud-platform scope. + * + * @throws {Error} If an explicit service account cannot be exchanged, or if + * no usable credentials are available at all. + */ + async getAccessToken(): Promise { + let token: string | null | undefined; + try { + const client = await this.auth.getClient(); + token = (await client.getAccessToken()).token; + } catch (err: unknown) { + throw this.credentialsError(err); + } + if (!token) { + throw new Error(MISSING_CREDENTIALS_MESSAGE); + } + return token; + } + + /** + * The billing/quota project advertised by the resolved credentials, if any. + * + * @throws {Error} If no usable credentials are available. + */ + async getQuotaProjectId(): Promise { + try { + return (await this.auth.getClient()).quotaProjectId; + } catch (err: unknown) { + throw this.credentialsError(err); + } + } + + private credentialsError(err: unknown): Error { + return this.hasExplicitServiceAccount + ? new Error(`Credentials error: ${toMessage(err)}`) + : new Error(MISSING_CREDENTIALS_MESSAGE); + } +} + +function createServiceAccountClient(serviceAccountJson: string): JWT { + const credential = parseServiceAccountCredential(serviceAccountJson); + return new JWT({ + email: credential.clientEmail, + key: credential.privateKey, + scopes: CLOUD_PLATFORM_SCOPES, + }); +} + +/** Options for {@link executeApiCall}. */ +export interface ApiCallOptions { + url: string; + method: 'GET' | 'POST'; + tokenProvider: AccessTokenProvider; + /** JSON request body; omitted for GET requests. */ + body?: unknown; + extraHeaders?: Record; + /** Message thrown when the API answers 400 or 404. */ + invalidRequestMessage: string; +} + +/** + * Issues an authenticated JSON request against a Google API and decodes the + * response, mapping transport and status failures onto the error messages the + * Application Integration tools report to callers. + * + * @throws {Error} `invalidRequestMessage` on 400/404, `Request error: ...` on + * any other failing status, and `An unexpected error occurred: ...` when the + * request cannot be completed at all. + */ +export async function executeApiCall(options: ApiCallOptions): Promise { + const token = await options.tokenProvider.getAccessToken(); + const headers: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + ...options.extraHeaders, + }; + + let response: Response; + try { + response = await globalThis.fetch(options.url, { + method: options.method, + headers, + body: + options.body === undefined ? undefined : JSON.stringify(options.body), + signal: AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MS), + }); + } catch (err: unknown) { + throw new Error(`An unexpected error occurred: ${toMessage(err)}`); + } + + if (!response.ok) { + if (response.status === 400 || response.status === 404) { + throw new Error(options.invalidRequestMessage); + } + throw new Error( + `Request error: ${response.status} ${await response.text()}`, + ); + } + + return (await response.json()) as T; +} diff --git a/core/src/tools/application_integration_tool/clients/connections_client.ts b/core/src/tools/application_integration_tool/clients/connections_client.ts new file mode 100644 index 000000000..7b4d69373 --- /dev/null +++ b/core/src/tools/application_integration_tool/clients/connections_client.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {experimental} from '../../../utils/experimental.js'; +import {AccessTokenProvider, executeApiCall} from './api_request.js'; + +/** Host serving the Integration Connectors admin API. */ +export const CONNECTORS_ENDPOINT = 'connectors.googleapis.com'; + +/** How many times a long-running operation is polled before giving up. */ +export const MAX_POLL_ATTEMPTS = 30; + +/** Delay between two polls of a long-running operation. */ +export const POLL_INTERVAL_MS = 1000; + +/** Service details of an Integration Connector connection. */ +export interface ConnectionDetails { + name: string; + serviceName: string; + host: string; + authOverrideEnabled: boolean; +} + +/** The JSON schema of an entity plus the operations it supports. */ +export interface EntitySchemaAndOperations { + schema: Record; + operations: string[]; +} + +/** The input/output schemas and metadata of a connector action. */ +export interface ActionSchema { + inputSchema: Record; + outputSchema: Record; + description: string; + displayName: string; +} + +/** Constructor options for {@link ConnectionsClient}. */ +export interface ConnectionsClientOptions { + /** The Google Cloud project ID. */ + project: string; + /** The Google Cloud location, e.g. `us-central1`. */ + location: string; + /** The connection name. */ + connection: string; + /** + * A service account key file. Required when Application Default Credentials + * are not available or should not be used. + */ + serviceAccountJson?: string; +} + +interface ConnectionResource { + name?: string; + serviceDirectory?: string; + tlsServiceDirectory?: string; + host?: string; + authOverrideEnabled?: boolean; +} + +interface OperationResource { + name?: string; + done?: boolean; + response?: { + jsonSchema?: Record; + operations?: string[]; + inputJsonSchema?: Record; + outputJsonSchema?: Record; + description?: string; + displayName?: string; + }; +} + +/** Client for the Google Cloud Integration Connectors API. */ +@experimental +export class ConnectionsClient { + readonly project: string; + readonly location: string; + readonly connection: string; + readonly connectorUrl: string; + private readonly tokenProvider: AccessTokenProvider; + + constructor(options: ConnectionsClientOptions) { + this.project = options.project; + this.location = options.location; + this.connection = options.connection; + this.connectorUrl = `https://${CONNECTORS_ENDPOINT}`; + this.tokenProvider = new AccessTokenProvider(options.serviceAccountJson); + } + + /** + * Retrieves the service name and host of the connection, along with whether + * the connection allows the caller to override its authentication. + */ + @experimental + async getConnectionDetails(): Promise { + const url = `${this.connectorUrl}/v1/projects/${this.project}/locations/${this.location}/connections/${this.connection}?view=BASIC`; + const connection = await this.get(url); + const host = connection.host ?? ''; + return { + name: connection.name ?? '', + serviceName: + (host ? connection.tlsServiceDirectory : connection.serviceDirectory) ?? + '', + host, + authOverrideEnabled: connection.authOverrideEnabled ?? false, + }; + } + + /** + * Retrieves the JSON schema of an entity and the operations the connector + * supports on it. + * + * @throws {Error} If the connector does not return a schema operation. + */ + @experimental + async getEntitySchemaAndOperations( + entity: string, + ): Promise { + const url = `${this.connectorUrl}/v1/projects/${this.project}/locations/${this.location}/connections/${this.connection}/connectionSchemaMetadata:getEntityType?entityId=${entity}`; + const operationId = (await this.get(url)).name; + if (!operationId) { + throw new Error( + `Failed to get entity schema and operations for entity: ${entity}`, + ); + } + + const operation = await this.pollOperation(operationId); + return { + schema: operation.response?.jsonSchema ?? {}, + operations: operation.response?.operations ?? [], + }; + } + + /** + * Retrieves the input and output JSON schemas of a connector action. + * + * @throws {Error} If the connector does not return a schema operation. + */ + @experimental + async getActionSchema(action: string): Promise { + const url = `${this.connectorUrl}/v1/projects/${this.project}/locations/${this.location}/connections/${this.connection}/connectionSchemaMetadata:getAction?actionId=${action}`; + const operationId = (await this.get(url)).name; + if (!operationId) { + throw new Error(`Failed to get action schema for action: ${action}`); + } + + const operation = await this.pollOperation(operationId); + return { + inputSchema: operation.response?.inputJsonSchema ?? {}, + outputSchema: operation.response?.outputJsonSchema ?? {}, + description: operation.response?.description ?? '', + displayName: operation.response?.displayName ?? '', + }; + } + + private get(url: string): Promise { + return executeApiCall({ + url, + method: 'GET', + tokenProvider: this.tokenProvider, + invalidRequestMessage: + 'Invalid request. Please check the provided values of' + + ` project(${this.project}), location(${this.location}),` + + ` connection(${this.connection}).`, + }); + } + + private async pollOperation(operationId: string): Promise { + const url = `${this.connectorUrl}/v1/${operationId}`; + for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) { + if (attempt > 0) { + await sleep(POLL_INTERVAL_MS); + } + const operation = await this.get(url); + if (operation.done) { + return operation; + } + } + throw new Error( + `Timed out waiting for operation ${operationId} to complete`, + ); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/core/src/tools/application_integration_tool/clients/connector_spec_builders.ts b/core/src/tools/application_integration_tool/clients/connector_spec_builders.ts new file mode 100644 index 000000000..2c4ab66fd --- /dev/null +++ b/core/src/tools/application_integration_tool/clients/connector_spec_builders.ts @@ -0,0 +1,494 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; + +/** + * Builders for the OpenAPI fragments that describe an Integration Connector + * connection as a set of callable operations. + * + * Every literal here is part of the generated spec that Application + * Integration consumes, so the shapes are kept identical to the adk-python + * implementation. + */ + +/** Host serving the Application Integration `:execute` endpoint. */ +export const INTEGRATIONS_ENDPOINT = 'integrations.googleapis.com'; + +/** Extension keys the connector spec adds to each generated operation. */ +export interface ConnectorOperationExtensions { + 'x-operation'?: string; + 'x-entity'?: string; + 'x-action'?: string; +} + +/** A generated connector operation. */ +export type ConnectorPathItem = + OpenAPIV3.PathItemObject; + +/** The spec generated for a connection, with its mutable sections required. */ +export type ConnectorSpec = OpenAPIV3.Document & { + components: OpenAPIV3.ComponentsObject & { + schemas: Record; + }; +}; + +const RESPONSE_REF = '#/components/schemas/execute-connector_Response'; + +/** Arguments every entity operation requires. */ +const ENTITY_CORE = [ + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'entity', +] as const; + +/** Arguments every action requires. */ +const ACTION_CORE = [ + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'action', +] as const; + +const EXECUTE_QUERY_INSTRUCTIONS = + ' Use pageSize = 50 and timeout = 120 until user specifies a different' + + ' value otherwise. If user provides a query in natural language, convert it' + + ' to SQL query and then execute it using the tool.'; + +function ref(name: string): OpenAPIV3.ReferenceObject { + return {$ref: `#/components/schemas/${name}`}; +} + +/** Maps each name onto a `$ref` to the component schema of the same name. */ +function refs( + ...names: readonly string[] +): Record { + return Object.fromEntries(names.map((name) => [name, ref(name)])); +} + +/** + * Description of the generated `list` operation. The embedded newlines and + * indentation are part of the reference spec and are reproduced verbatim. + */ +function listDescription(entity: string, toolInstructions: string): string { + return `Returns the list of ${entity} data. If the page token was available in the response, let users know there are more records available. Ask if the user wants to fetch the next page of results. When passing filter use the + following format: \`field_name1='value1' AND field_name2='value2' + \`. ${toolInstructions}`; +} + +interface EntityOperationTemplate { + operation: string; + summary: (entity: string) => string; + description: (entity: string, toolInstructions: string) => string; + /** Only the read operations echo the entity's schema in their response. */ + responseDescription?: (entity: string, schemaAsString: string) => string; + request: (entity: string) => OpenAPIV3.SchemaObject; +} + +/** How each supported entity operation differs from its siblings. */ +const ENTITY_OPERATIONS: Record = { + list: { + operation: 'LIST_ENTITIES', + summary: (entity) => `List ${entity}`, + description: listDescription, + responseDescription: (entity, schema) => + `Returns a list of ${entity} of json schema: ${schema}`, + request: () => ({ + type: 'object', + required: [...ENTITY_CORE], + properties: refs( + 'filterClause', + 'pageSize', + 'pageToken', + ...ENTITY_CORE, + 'sortByColumns', + 'dynamicAuthConfig', + ), + }), + }, + get: { + operation: 'GET_ENTITY', + summary: (entity) => `Get ${entity}`, + description: (entity, instructions) => + `Returns the details of the ${entity}. ${instructions}`, + responseDescription: (entity, schema) => + `Returns ${entity} of json schema: ${schema}`, + request: () => ({ + type: 'object', + required: ['entityId', ...ENTITY_CORE], + properties: refs('entityId', ...ENTITY_CORE, 'dynamicAuthConfig'), + }), + }, + create: { + operation: 'CREATE_ENTITY', + summary: (entity) => `Creates a new ${entity}`, + description: (entity, instructions) => + `Creates a new ${entity}. ${instructions}`, + request: (entity) => ({ + type: 'object', + required: ['connectorInputPayload', ...ENTITY_CORE], + properties: { + connectorInputPayload: ref(`connectorInputPayload_${entity}`), + ...refs(...ENTITY_CORE, 'dynamicAuthConfig'), + }, + }), + }, + update: { + operation: 'UPDATE_ENTITY', + summary: (entity) => `Updates the ${entity}`, + description: (entity, instructions) => + `Updates the ${entity}. ${instructions}`, + request: (entity) => ({ + type: 'object', + required: ['connectorInputPayload', 'entityId', ...ENTITY_CORE], + properties: { + connectorInputPayload: ref(`connectorInputPayload_${entity}`), + ...refs( + 'entityId', + ...ENTITY_CORE, + 'dynamicAuthConfig', + 'filterClause', + ), + }, + }), + }, + delete: { + operation: 'DELETE_ENTITY', + summary: (entity) => `Delete the ${entity}`, + description: (entity, instructions) => + `Deletes the ${entity}. ${instructions}`, + request: () => ({ + type: 'object', + required: ['entityId', ...ENTITY_CORE], + properties: refs( + 'entityId', + ...ENTITY_CORE, + 'dynamicAuthConfig', + 'filterClause', + ), + }), + }, +}; + +/** Arguments for {@link buildEntityOperation}. */ +export interface EntityOperationRequest { + /** The connector operation, e.g. `LIST`. Matched case-insensitively. */ + operation: string; + entity: string; + /** The entity's JSON schema, echoed in a read operation's response. */ + schemaAsString: string; + toolName: string; + toolInstructions: string; +} + +/** The path item and request schema that publish one entity operation. */ +export interface EntityOperation { + path: ConnectorPathItem; + requestSchemaName: string; + requestSchema: OpenAPIV3.SchemaObject; +} + +/** + * Builds the spec fragments that publish one operation on an entity. + * + * @returns The fragments, or `undefined` if the connector spec cannot express + * the requested operation. + */ +export function buildEntityOperation( + request: EntityOperationRequest, +): EntityOperation | undefined { + const verb = request.operation.toLowerCase(); + const template = ENTITY_OPERATIONS[verb]; + if (!template) { + return undefined; + } + + const {entity, schemaAsString, toolName, toolInstructions} = request; + const requestSchemaName = `${verb}_${entity}_Request`; + const responseDescription = template.responseDescription?.( + entity, + schemaAsString, + ); + + return { + requestSchemaName, + requestSchema: template.request(entity), + path: { + post: { + summary: template.summary(entity), + description: template.description(entity, toolInstructions), + 'x-operation': template.operation, + 'x-entity': entity, + operationId: `${toolName}_${verb}_${entity}`, + requestBody: { + content: {'application/json': {schema: ref(requestSchemaName)}}, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: responseDescription + ? {description: responseDescription, $ref: RESPONSE_REF} + : {$ref: RESPONSE_REF}, + }, + }, + }, + }, + }, + }, + }; +} + +/** Returns the skeleton spec that every connector operation is added to. */ +export function getConnectorBaseSpec(): ConnectorSpec { + return { + openapi: '3.0.1', + info: { + title: 'ExecuteConnection', + description: 'This tool can execute a query on connection', + version: '4', + }, + servers: [{url: `https://${INTEGRATIONS_ENDPOINT}`}], + security: [ + {google_auth: ['https://www.googleapis.com/auth/cloud-platform']}, + ], + paths: {}, + components: { + schemas: { + operation: { + type: 'string', + default: 'LIST_ENTITIES', + description: + 'Operation to execute. Possible values are LIST_ENTITIES,' + + ' GET_ENTITY, CREATE_ENTITY, UPDATE_ENTITY, DELETE_ENTITY in case' + + ' of entities. EXECUTE_ACTION in case of actions. and' + + ' EXECUTE_QUERY in case of custom queries.', + }, + entityId: { + type: 'string', + description: 'Name of the entity', + }, + connectorInputPayload: {type: 'object'}, + filterClause: { + type: 'string', + default: '', + description: 'WHERE clause in SQL query', + }, + pageSize: { + type: 'integer', + default: 50, + description: 'Number of entities to return in the response', + }, + pageToken: { + type: 'string', + default: '', + description: 'Page token to return the next page of entities', + }, + connectionName: { + type: 'string', + default: '', + description: 'Connection resource name to run the query for', + }, + serviceName: { + type: 'string', + default: '', + description: 'Service directory for the connection', + }, + host: { + type: 'string', + default: '', + description: 'Host name in case of tls service directory', + }, + entity: { + type: 'string', + default: 'Issues', + description: 'Entity to run the query for', + }, + action: { + type: 'string', + default: 'ExecuteCustomQuery', + description: 'Action to run the query for', + }, + query: { + type: 'string', + default: '', + description: 'Custom Query to execute on the connection', + }, + dynamicAuthConfig: { + type: 'object', + default: {}, + description: 'Dynamic auth config for the connection', + }, + timeout: { + type: 'integer', + default: 120, + description: 'Timeout in seconds for execution of custom query', + }, + sortByColumns: { + type: 'array', + items: {type: 'string'}, + default: [], + description: 'Column to sort the results by', + }, + connectorOutputPayload: {type: 'object'}, + nextPageToken: {type: 'string'}, + 'execute-connector_Response': { + required: ['connectorOutputPayload'], + type: 'object', + properties: refs('connectorOutputPayload', 'nextPageToken'), + }, + }, + securitySchemes: { + google_auth: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'https://accounts.google.com/o/oauth2/auth', + scopes: { + 'https://www.googleapis.com/auth/cloud-platform': + 'Auth for google cloud services', + }, + }, + }, + }, + }, + }, + }; +} + +/** Path item for executing a connector action. */ +export function getActionOperation( + action: string, + operation: string, + actionDisplayName: string, + toolName: string, + toolInstructions: string, +): ConnectorPathItem { + let description = `Use this tool to execute ${action}`; + if (operation === 'EXECUTE_QUERY') { + description += EXECUTE_QUERY_INSTRUCTIONS; + } + return { + post: { + summary: actionDisplayName, + description: `${description} ${toolInstructions}`, + operationId: `${toolName}_${actionDisplayName}`, + 'x-action': action, + 'x-operation': operation, + requestBody: { + content: { + 'application/json': {schema: ref(`${actionDisplayName}_Request`)}, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': {schema: ref(`${actionDisplayName}_Response`)}, + }, + }, + }, + }, + }; +} + +/** Request schema for executing an action. */ +export function actionRequest(action: string): OpenAPIV3.SchemaObject { + return { + type: 'object', + required: [...ACTION_CORE, 'connectorInputPayload'], + properties: { + ...refs(...ACTION_CORE), + connectorInputPayload: ref(`connectorInputPayload_${action}`), + ...refs('dynamicAuthConfig'), + }, + }; +} + +/** Response schema for executing an action. */ +export function actionResponse(action: string): OpenAPIV3.SchemaObject { + return { + type: 'object', + properties: { + connectorOutputPayload: ref(`connectorOutputPayload_${action}`), + }, + }; +} + +/** Request schema for the built-in custom-query action. */ +export function executeCustomQueryRequest(): OpenAPIV3.SchemaObject { + return { + type: 'object', + required: [...ACTION_CORE, 'query', 'timeout', 'pageSize'], + properties: refs( + ...ACTION_CORE, + 'query', + 'timeout', + 'pageSize', + 'dynamicAuthConfig', + ), + }; +} + +/** + * Converts a JSON schema into an OpenAPI schema, mapping nullable union types + * onto `nullable` and recursing into object properties and array items. + * + * Keys the connector spec does not use are dropped. + */ +export function convertJsonSchemaToOpenApiSchema( + jsonSchema: Record, +): Record { + const openApiSchema: Record = {}; + + if ('description' in jsonSchema) { + openApiSchema['description'] = jsonSchema['description']; + } + + const type = jsonSchema['type']; + if (Array.isArray(type)) { + if (type.includes('null')) { + openApiSchema['nullable'] = true; + const otherTypes = type.filter((entry) => entry !== 'null'); + if (otherTypes.length > 0) { + openApiSchema['type'] = otherTypes[0]; + } + } else { + openApiSchema['type'] = type[0]; + } + } else if (type !== undefined) { + openApiSchema['type'] = type; + } + + const properties = jsonSchema['properties']; + const items = jsonSchema['items']; + + if (openApiSchema['type'] === 'object' && isRecord(properties)) { + const converted: Record = {}; + for (const [name, schema] of Object.entries(properties)) { + converted[name] = convertJsonSchemaToOpenApiSchema(asRecord(schema)); + } + openApiSchema['properties'] = converted; + } else if (openApiSchema['type'] === 'array' && items !== undefined) { + openApiSchema['items'] = Array.isArray(items) + ? items.map((item) => convertJsonSchemaToOpenApiSchema(asRecord(item))) + : convertJsonSchemaToOpenApiSchema(asRecord(items)); + } + + return openApiSchema; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} diff --git a/core/src/tools/application_integration_tool/clients/integration_client.ts b/core/src/tools/application_integration_tool/clients/integration_client.ts new file mode 100644 index 000000000..1da948389 --- /dev/null +++ b/core/src/tools/application_integration_tool/clients/integration_client.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {experimental} from '../../../utils/experimental.js'; +import {AccessTokenProvider, executeApiCall} from './api_request.js'; +import {ConnectionsClient} from './connections_client.js'; +import { + actionRequest, + actionResponse, + buildEntityOperation, + convertJsonSchemaToOpenApiSchema, + executeCustomQueryRequest, + getActionOperation, + getConnectorBaseSpec, +} from './connector_spec_builders.js'; + +/** Integration published by default to execute connector operations. */ +export const DEFAULT_CONNECTION_INTEGRATION = 'ExecuteConnection'; + +/** The connector action that runs a caller-supplied SQL query. */ +export const EXECUTE_CUSTOM_QUERY_ACTION = 'ExecuteCustomQuery'; + +/** + * Entity name to the operations that should be published for it. An empty + * array means every operation the connector supports. + */ +export type EntityOperations = Record; + +/** Constructor options for {@link IntegrationClient}. */ +export interface IntegrationClientOptions { + /** The Google Cloud project ID. */ + project: string; + /** The Google Cloud location, e.g. `us-central1`. */ + location: string; + /** Overrides the default `ExecuteConnection` integration name. */ + connectionTemplateOverride?: string; + /** The integration name. */ + integration?: string; + /** The trigger IDs to publish from the integration. */ + triggers?: string[]; + /** The connection name. */ + connection?: string; + /** The entity operations to publish from the connection. */ + entityOperations?: EntityOperations; + /** The actions to publish from the connection. */ + actions?: string[]; + /** + * A service account key file. Required when Application Default Credentials + * are not available or should not be used. + */ + serviceAccountJson?: string; +} + +interface GenerateOpenApiSpecResponse { + openApiSpec?: string; +} + +/** + * Client for Google Cloud Application Integration. + * + * Produces the OpenAPI spec describing either an integration's API triggers or + * an Integration Connector connection's entity operations and actions. + */ +@experimental +export class IntegrationClient { + private readonly options: IntegrationClientOptions; + private readonly tokenProvider: AccessTokenProvider; + + constructor(options: IntegrationClientOptions) { + this.options = options; + this.tokenProvider = new AccessTokenProvider(options.serviceAccountJson); + } + + /** Fetches the OpenAPI spec that Application Integration generates. */ + @experimental + async getOpenApiSpecForIntegration(): Promise { + const {project, location, integration, triggers, serviceAccountJson} = + this.options; + const extraHeaders: Record = {}; + if (!serviceAccountJson) { + extraHeaders['x-goog-user-project'] = + (await this.tokenProvider.getQuotaProjectId()) ?? project; + } + + const response = await executeApiCall({ + url: `https://${location}-integrations.googleapis.com/v1/projects/${project}/locations/${location}:generateOpenApiSpec`, + method: 'POST', + tokenProvider: this.tokenProvider, + extraHeaders, + body: { + apiTriggerResources: [ + {integrationResource: integration, triggerId: triggers}, + ], + fileFormat: 'JSON', + }, + invalidRequestMessage: + 'Invalid request. Please check the provided values of' + + ` project(${project}), location(${location}),` + + ` integration(${integration}).`, + }); + + // The API returns the document as a JSON string, so it is decoded twice. + return JSON.parse(response.openApiSpec ?? '{}') as OpenAPIV3.Document; + } + + /** + * Builds the OpenAPI spec that publishes a connection's entity operations + * and actions as Application Integration executions. + * + * @param toolName Prefix prepended to every generated operation ID. + * @param toolInstructions Appended to every generated description. + * @throws {Error} If neither entity operations nor actions were configured. + */ + @experimental + async getOpenApiSpecForConnection( + toolName = '', + toolInstructions = '', + ): Promise { + const {project, location, entityOperations, actions} = this.options; + if (!hasEntries(entityOperations) && !actions?.length) { + throw new Error( + 'No entity operations or actions provided. Please provide at least' + + ' one of them.', + ); + } + + // Application Integration must be provisioned in the connection's region + // with an integration of this name and a matching `api_trigger/`. + const integrationName = + this.options.connectionTemplateOverride || DEFAULT_CONNECTION_INTEGRATION; + const connectionsClient = new ConnectionsClient({ + project, + location, + connection: this.options.connection ?? '', + serviceAccountJson: this.options.serviceAccountJson, + }); + const executePath = `/v2/projects/${project}/locations/${location}/integrations/${integrationName}:execute?triggerId=api_trigger/${integrationName}`; + const spec = getConnectorBaseSpec(); + + for (const [entity, operations] of Object.entries(entityOperations ?? {})) { + const {schema, operations: supportedOperations} = + await connectionsClient.getEntitySchemaAndOperations(entity); + spec.components.schemas[`connectorInputPayload_${entity}`] = + convertJsonSchemaToOpenApiSchema(schema); + + const schemaAsString = JSON.stringify(schema); + for (const operation of operations.length + ? operations + : supportedOperations) { + const built = buildEntityOperation({ + entity, + operation, + schemaAsString, + toolName, + toolInstructions, + }); + if (!built) { + throw new Error( + `Invalid operation: ${operation} for entity: ${entity}`, + ); + } + spec.paths[`${executePath}#${operation.toLowerCase()}_${entity}`] = + built.path; + spec.components.schemas[built.requestSchemaName] = built.requestSchema; + } + } + + for (const action of actions ?? []) { + const actionDetails = await connectionsClient.getActionSchema(action); + // A display name with spaces would produce an invalid spec. + const displayName = actionDetails.displayName.replaceAll(' ', ''); + let operation = 'EXECUTE_ACTION'; + + if (action === EXECUTE_CUSTOM_QUERY_ACTION) { + spec.components.schemas[`${displayName}_Request`] = + executeCustomQueryRequest(); + operation = 'EXECUTE_QUERY'; + } else { + spec.components.schemas[`${displayName}_Request`] = + actionRequest(displayName); + spec.components.schemas[`connectorInputPayload_${displayName}`] = + convertJsonSchemaToOpenApiSchema(actionDetails.inputSchema); + } + spec.components.schemas[`connectorOutputPayload_${displayName}`] = + convertJsonSchemaToOpenApiSchema(actionDetails.outputSchema); + spec.components.schemas[`${displayName}_Response`] = + actionResponse(displayName); + spec.paths[`${executePath}#${action}`] = getActionOperation( + action, + operation, + displayName, + toolName, + toolInstructions, + ); + } + + return spec; + } +} + +function hasEntries(value?: EntityOperations): boolean { + return value !== undefined && Object.keys(value).length > 0; +} diff --git a/core/src/tools/openapi_tool/rest_api_tool.ts b/core/src/tools/openapi_tool/rest_api_tool.ts index 46f2efe20..2d112032c 100644 --- a/core/src/tools/openapi_tool/rest_api_tool.ts +++ b/core/src/tools/openapi_tool/rest_api_tool.ts @@ -205,6 +205,12 @@ export function prepareRequestParams( url = url.replace(`{${key}}`, value); } + // A fragment is never sent to the server. A spec may append one purely to + // keep operations that share an endpoint distinct (the Integration Connector + // spec appends `#_`), so it is dropped before the query + // string is read rather than being folded into it. + url = url.split('#')[0]; + // Extract query parameters from path if any const urlParts = url.split('?'); if (urlParts.length > 1) { diff --git a/core/src/utils/error_utils.ts b/core/src/utils/error_utils.ts new file mode 100644 index 000000000..2b2a9b698 --- /dev/null +++ b/core/src/utils/error_utils.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Narrows an unknown thrown value to a human-readable message. + * + * @param err The value a `catch` block received. + * @returns The `Error`'s message, or the value rendered as a string. + */ +export function toMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/core/src/utils/service_account_utils.ts b/core/src/utils/service_account_utils.ts new file mode 100644 index 000000000..c1e38f8ba --- /dev/null +++ b/core/src/utils/service_account_utils.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ServiceAccountCredential} from '../auth/auth_credential.js'; +import {camelCaseKeys} from './case_utils.js'; + +/** Fields a Google service-account key file must carry to be usable. */ +const REQUIRED_FIELDS = [ + 'projectId', + 'privateKeyId', + 'privateKey', + 'clientEmail', + 'clientId', + 'authUri', + 'tokenUri', + 'authProviderX509CertUrl', + 'clientX509CertUrl', + 'universeDomain', +] as const satisfies ReadonlyArray; + +function isServiceAccountCredential( + value: unknown, +): value is ServiceAccountCredential { + if (typeof value !== 'object' || value === null) { + return false; + } + const candidate = value as Record; + if (candidate['type'] !== 'service_account') { + return false; + } + return REQUIRED_FIELDS.every((field) => typeof candidate[field] === 'string'); +} + +/** + * Parses a Google service-account key file into a `ServiceAccountCredential`. + * + * Key files are emitted with snake_case field names, so the parsed object is + * mapped onto the camelCase shape ADK uses before it is validated. + * + * @param serviceAccountJson The contents of a service-account key file. + * @throws {Error} If the string is not JSON, or is not a complete key file. + * A parse failure is reported with the underlying error as its `cause`. + * @returns The parsed credential. + */ +export function parseServiceAccountCredential( + serviceAccountJson: string, +): ServiceAccountCredential { + let parsed: unknown; + try { + parsed = JSON.parse(serviceAccountJson); + } catch (err: unknown) { + throw new Error('Invalid service account JSON.', {cause: err}); + } + + const credential = camelCaseKeys(parsed); + if (!isServiceAccountCredential(credential)) { + throw new Error( + 'Invalid service account JSON: expected a service account key file.', + ); + } + return credential; +} diff --git a/core/test/tools/application_integration_tool/clients/api_request_test.ts b/core/test/tools/application_integration_tool/clients/api_request_test.ts new file mode 100644 index 000000000..b619c3306 --- /dev/null +++ b/core/test/tools/application_integration_tool/clients/api_request_test.ts @@ -0,0 +1,288 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + AccessTokenProvider, + DEFAULT_REQUEST_TIMEOUT_MS, + executeApiCall, +} from '../../../../src/tools/application_integration_tool/clients/api_request.js'; + +const CLOUD_PLATFORM_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', +]; + +const MISSING_CREDENTIALS_MESSAGE = + 'Please provide a service account that has the required permissions to' + + ' access the connection.'; + +const SERVICE_ACCOUNT_JSON = JSON.stringify({ + 'type': 'service_account', + 'project_id': 'dummy', + 'private_key_id': 'dummy-key-id', + 'private_key': 'dummy-private-key', + 'client_email': 'test@example.com', + 'client_id': '131331543646416', + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', + 'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs', + 'client_x509_cert_url': 'https://www.googleapis.com/robot/v1/x509/dummy', + 'universe_domain': 'googleapis.com', +}); + +const {authClient, googleAuthCtor, jwtCtor} = vi.hoisted(() => { + const authClient = { + getAccessToken: vi.fn(), + quotaProjectId: undefined as string | undefined, + }; + return {authClient, googleAuthCtor: vi.fn(), jwtCtor: vi.fn()}; +}); + +vi.mock('google-auth-library', () => ({ + GoogleAuth: googleAuthCtor, + JWT: jwtCtor, +})); + +describe('AccessTokenProvider', () => { + beforeEach(() => { + authClient.quotaProjectId = undefined; + authClient.getAccessToken.mockResolvedValue({token: 'test_token'}); + googleAuthCtor.mockImplementation(() => ({ + getClient: async () => authClient, + })); + jwtCtor.mockImplementation((options: unknown) => options); + }); + + afterEach(() => { + vi.restoreAllMocks(); + googleAuthCtor.mockReset(); + jwtCtor.mockReset(); + authClient.getAccessToken.mockReset(); + }); + + it('resolves a token from application default credentials', async () => { + const token = await new AccessTokenProvider().getAccessToken(); + + expect(token).toBe('test_token'); + expect(googleAuthCtor).toHaveBeenCalledWith({ + scopes: CLOUD_PLATFORM_SCOPES, + }); + }); + + it('resolves a token from an explicit service account key file', async () => { + const token = await new AccessTokenProvider( + SERVICE_ACCOUNT_JSON, + ).getAccessToken(); + + expect(token).toBe('test_token'); + expect(jwtCtor).toHaveBeenCalledWith({ + email: 'test@example.com', + key: 'dummy-private-key', + scopes: CLOUD_PLATFORM_SCOPES, + }); + expect(googleAuthCtor).toHaveBeenCalledWith({ + authClient: { + email: 'test@example.com', + key: 'dummy-private-key', + scopes: CLOUD_PLATFORM_SCOPES, + }, + scopes: CLOUD_PLATFORM_SCOPES, + }); + }); + + it('reports a service account exchange failure as a credentials error', async () => { + authClient.getAccessToken.mockRejectedValue(new Error('invalid_grant')); + + await expect( + new AccessTokenProvider(SERVICE_ACCOUNT_JSON).getAccessToken(), + ).rejects.toThrow('Credentials error: invalid_grant'); + }); + + it('asks for a service account when default credentials are unavailable', async () => { + googleAuthCtor.mockImplementation(() => ({ + getClient: async () => { + throw new Error('Could not load the default credentials'); + }, + })); + + await expect(new AccessTokenProvider().getAccessToken()).rejects.toThrow( + MISSING_CREDENTIALS_MESSAGE, + ); + }); + + it('asks for a service account when no token is issued', async () => { + authClient.getAccessToken.mockResolvedValue({token: null}); + + await expect(new AccessTokenProvider().getAccessToken()).rejects.toThrow( + MISSING_CREDENTIALS_MESSAGE, + ); + }); + + it('exposes the quota project of the resolved credentials', async () => { + authClient.quotaProjectId = 'quota-project'; + + await expect(new AccessTokenProvider().getQuotaProjectId()).resolves.toBe( + 'quota-project', + ); + }); + + it('reports the same failure when the quota project is unavailable', async () => { + googleAuthCtor.mockImplementation(() => ({ + getClient: async () => { + throw new Error('Could not load the default credentials'); + }, + })); + + await expect(new AccessTokenProvider().getQuotaProjectId()).rejects.toThrow( + MISSING_CREDENTIALS_MESSAGE, + ); + await expect( + new AccessTokenProvider(SERVICE_ACCOUNT_JSON).getQuotaProjectId(), + ).rejects.toThrow( + 'Credentials error: Could not load the default credentials', + ); + }); + + it('requests a fresh token per call rather than caching one', async () => { + const provider = new AccessTokenProvider(); + + await provider.getAccessToken(); + await provider.getAccessToken(); + + expect(googleAuthCtor).toHaveBeenCalledTimes(1); + expect(authClient.getAccessToken).toHaveBeenCalledTimes(2); + }); +}); + +describe('executeApiCall', () => { + let tokenProvider: AccessTokenProvider; + + beforeEach(() => { + authClient.getAccessToken.mockResolvedValue({token: 'test_token'}); + googleAuthCtor.mockImplementation(() => ({ + getClient: async () => authClient, + })); + tokenProvider = new AccessTokenProvider(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + googleAuthCtor.mockReset(); + authClient.getAccessToken.mockReset(); + }); + + it('sends an authenticated GET and decodes the response', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({data: 'test'}))); + const timeoutSpy = vi.spyOn(AbortSignal, 'timeout'); + + const result = await executeApiCall<{data: string}>({ + url: 'https://test.url', + method: 'GET', + tokenProvider, + invalidRequestMessage: 'invalid', + }); + + expect(result).toEqual({data: 'test'}); + expect(timeoutSpy).toHaveBeenCalledWith(DEFAULT_REQUEST_TIMEOUT_MS); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://test.url'); + expect(init?.method).toBe('GET'); + expect(init?.headers).toEqual({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test_token', + }); + expect(init?.body).toBeUndefined(); + expect(init?.signal).toBeInstanceOf(AbortSignal); + }); + + it('serializes the body and merges extra headers on POST', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({ok: true}))); + + await executeApiCall({ + url: 'https://test.url', + method: 'POST', + tokenProvider, + body: {fileFormat: 'JSON'}, + extraHeaders: {'x-goog-user-project': 'quota-project'}, + invalidRequestMessage: 'invalid', + }); + + const [, init] = fetchMock.mock.calls[0]; + expect(init?.body).toBe('{"fileFormat":"JSON"}'); + expect(init?.headers).toEqual({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test_token', + 'x-goog-user-project': 'quota-project', + }); + }); + + it.each([400, 404])( + 'reports status %i as an invalid request', + async (status) => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Bad Request', {status}), + ); + + await expect( + executeApiCall({ + url: 'https://test.url', + method: 'GET', + tokenProvider, + invalidRequestMessage: 'Invalid request. Please check the values.', + }), + ).rejects.toThrow('Invalid request. Please check the values.'); + }, + ); + + it('reports any other failing status as a request error', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Internal Server Error', {status: 500}), + ); + + await expect( + executeApiCall({ + url: 'https://test.url', + method: 'GET', + tokenProvider, + invalidRequestMessage: 'invalid', + }), + ).rejects.toThrow('Request error: 500 Internal Server Error'); + }); + + it('reports a transport failure as an unexpected error', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue( + new Error('Something went wrong'), + ); + + await expect( + executeApiCall({ + url: 'https://test.url', + method: 'GET', + tokenProvider, + invalidRequestMessage: 'invalid', + }), + ).rejects.toThrow('An unexpected error occurred: Something went wrong'); + }); + + it('does not send a request when no credentials are available', async () => { + authClient.getAccessToken.mockResolvedValue({token: undefined}); + const fetchMock = vi.spyOn(globalThis, 'fetch'); + + await expect( + executeApiCall({ + url: 'https://test.url', + method: 'GET', + tokenProvider, + invalidRequestMessage: 'invalid', + }), + ).rejects.toThrow(MISSING_CREDENTIALS_MESSAGE); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/core/test/tools/application_integration_tool/clients/connections_client_test.ts b/core/test/tools/application_integration_tool/clients/connections_client_test.ts new file mode 100644 index 000000000..d3195cbca --- /dev/null +++ b/core/test/tools/application_integration_tool/clients/connections_client_test.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + ConnectionsClient, + MAX_POLL_ATTEMPTS, + POLL_INTERVAL_MS, +} from '../../../../src/tools/application_integration_tool/clients/connections_client.js'; + +const PROJECT = 'test-project'; +const LOCATION = 'us-central1'; +const CONNECTION = 'test-connection'; +const CONNECTION_URL = `https://connectors.googleapis.com/v1/projects/${PROJECT}/locations/${LOCATION}/connections/${CONNECTION}`; + +const {authClient, googleAuthCtor} = vi.hoisted(() => ({ + authClient: {getAccessToken: vi.fn(), quotaProjectId: undefined}, + googleAuthCtor: vi.fn(), +})); + +vi.mock('google-auth-library', () => ({ + GoogleAuth: googleAuthCtor, + JWT: vi.fn(), +})); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), {status}); +} + +function newClient(): ConnectionsClient { + return new ConnectionsClient({ + project: PROJECT, + location: LOCATION, + connection: CONNECTION, + }); +} + +describe('ConnectionsClient', () => { + beforeEach(() => { + authClient.getAccessToken.mockResolvedValue({token: 'test_token'}); + googleAuthCtor.mockImplementation(() => ({ + getClient: async () => authClient, + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + googleAuthCtor.mockReset(); + authClient.getAccessToken.mockReset(); + }); + + it('targets the public connectors endpoint', () => { + expect(newClient().connectorUrl).toBe('https://connectors.googleapis.com'); + }); + + it('reads the tls service directory when the connection has a host', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + name: 'test-connection', + serviceDirectory: 'test_service', + host: 'test.host', + tlsServiceDirectory: 'tls_test_service', + authOverrideEnabled: true, + }), + ); + + await expect(newClient().getConnectionDetails()).resolves.toEqual({ + name: 'test-connection', + serviceName: 'tls_test_service', + host: 'test.host', + authOverrideEnabled: true, + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${CONNECTION_URL}?view=BASIC`); + expect(init?.method).toBe('GET'); + expect(init?.headers).toEqual({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test_token', + }); + }); + + it('reads the plain service directory when the connection has no host', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + name: 'test-connection', + serviceDirectory: 'test_service', + authOverrideEnabled: false, + }), + ); + + await expect(newClient().getConnectionDetails()).resolves.toEqual({ + name: 'test-connection', + serviceName: 'test_service', + host: '', + authOverrideEnabled: false, + }); + }); + + it('defaults missing connection fields', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({})); + + await expect(newClient().getConnectionDetails()).resolves.toEqual({ + name: '', + serviceName: '', + host: '', + authOverrideEnabled: false, + }); + }); + + it('names the connection in the invalid request message', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Not Found', {status: 404}), + ); + + await expect(newClient().getConnectionDetails()).rejects.toThrow( + `Invalid request. Please check the provided values of project(${PROJECT}),` + + ` location(${LOCATION}), connection(${CONNECTION}).`, + ); + }); + + it('returns the entity schema and its supported operations', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({name: 'operations/test_op'})) + .mockResolvedValueOnce( + jsonResponse({ + done: true, + response: { + jsonSchema: {type: 'object'}, + operations: ['LIST', 'GET'], + }, + }), + ); + + await expect( + newClient().getEntitySchemaAndOperations('entity1'), + ).resolves.toEqual({ + schema: {type: 'object'}, + operations: ['LIST', 'GET'], + }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + `${CONNECTION_URL}/connectionSchemaMetadata:getEntityType?entityId=entity1`, + 'https://connectors.googleapis.com/v1/operations/test_op', + ]); + }); + + it('defaults an entity operation response with no payload', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({name: 'operations/test_op'})) + .mockResolvedValueOnce(jsonResponse({done: true})); + + await expect( + newClient().getEntitySchemaAndOperations('entity1'), + ).resolves.toEqual({schema: {}, operations: []}); + }); + + it('fails when the entity schema operation is missing', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({})); + + await expect( + newClient().getEntitySchemaAndOperations('entity1'), + ).rejects.toThrow( + 'Failed to get entity schema and operations for entity: entity1', + ); + }); + + it('returns the action schema', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({name: 'operations/test_op'})) + .mockResolvedValueOnce( + jsonResponse({ + done: true, + response: { + inputJsonSchema: {type: 'object'}, + outputJsonSchema: {type: 'string'}, + description: 'Test Action Description', + displayName: 'TestAction', + }, + }), + ); + + await expect(newClient().getActionSchema('action1')).resolves.toEqual({ + inputSchema: {type: 'object'}, + outputSchema: {type: 'string'}, + description: 'Test Action Description', + displayName: 'TestAction', + }); + expect(fetchMock.mock.calls[0][0]).toBe( + `${CONNECTION_URL}/connectionSchemaMetadata:getAction?actionId=action1`, + ); + }); + + it('defaults an action operation response with no payload', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({name: 'operations/test_op'})) + .mockResolvedValueOnce(jsonResponse({done: true})); + + await expect(newClient().getActionSchema('action1')).resolves.toEqual({ + inputSchema: {}, + outputSchema: {}, + description: '', + displayName: '', + }); + }); + + it('fails when the action schema operation is missing', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({})); + + await expect(newClient().getActionSchema('action1')).rejects.toThrow( + 'Failed to get action schema for action: action1', + ); + }); + + it('keeps polling until the operation reports done', async () => { + vi.useFakeTimers(); + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({name: 'operations/test_op'})) + .mockResolvedValueOnce(jsonResponse({done: false})) + .mockResolvedValueOnce( + jsonResponse({done: true, response: {operations: ['LIST']}}), + ); + + const pending = newClient().getEntitySchemaAndOperations('entity1'); + await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS); + + await expect(pending).resolves.toEqual({schema: {}, operations: ['LIST']}); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it('gives up once the poll budget is exhausted', async () => { + vi.useFakeTimers(); + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({name: 'operations/test_op'})) + .mockImplementation(async () => jsonResponse({done: false})); + + const pending = newClient().getEntitySchemaAndOperations('entity1'); + const assertion = expect(pending).rejects.toThrow( + 'Timed out waiting for operation operations/test_op to complete', + ); + await vi.advanceTimersByTimeAsync(MAX_POLL_ATTEMPTS * POLL_INTERVAL_MS); + + await assertion; + }); +}); diff --git a/core/test/tools/application_integration_tool/clients/connector_spec_builders_test.ts b/core/test/tools/application_integration_tool/clients/connector_spec_builders_test.ts new file mode 100644 index 000000000..220feb7f6 --- /dev/null +++ b/core/test/tools/application_integration_tool/clients/connector_spec_builders_test.ts @@ -0,0 +1,830 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import { + actionRequest, + actionResponse, + buildEntityOperation, + convertJsonSchemaToOpenApiSchema, + executeCustomQueryRequest, + getActionOperation, + getConnectorBaseSpec, +} from '../../../../src/tools/application_integration_tool/clients/connector_spec_builders.js'; + +/** + * Expected fragments transcribed from the adk-python reference implementation + * (`clients/connections_client.py`). Whole objects are asserted so that any + * drift from the wire format fails loudly. + */ +const BASE_SPEC = { + openapi: '3.0.1', + info: { + title: 'ExecuteConnection', + description: 'This tool can execute a query on connection', + version: '4', + }, + servers: [ + { + url: 'https://integrations.googleapis.com', + }, + ], + security: [ + { + google_auth: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ], + paths: {}, + components: { + schemas: { + operation: { + type: 'string', + default: 'LIST_ENTITIES', + description: + 'Operation to execute. Possible values are LIST_ENTITIES, GET_ENTITY, CREATE_ENTITY, UPDATE_ENTITY, DELETE_ENTITY in case of entities. EXECUTE_ACTION in case of actions. and EXECUTE_QUERY in case of custom queries.', + }, + entityId: { + type: 'string', + description: 'Name of the entity', + }, + connectorInputPayload: { + type: 'object', + }, + filterClause: { + type: 'string', + default: '', + description: 'WHERE clause in SQL query', + }, + pageSize: { + type: 'integer', + default: 50, + description: 'Number of entities to return in the response', + }, + pageToken: { + type: 'string', + default: '', + description: 'Page token to return the next page of entities', + }, + connectionName: { + type: 'string', + default: '', + description: 'Connection resource name to run the query for', + }, + serviceName: { + type: 'string', + default: '', + description: 'Service directory for the connection', + }, + host: { + type: 'string', + default: '', + description: 'Host name in case of tls service directory', + }, + entity: { + type: 'string', + default: 'Issues', + description: 'Entity to run the query for', + }, + action: { + type: 'string', + default: 'ExecuteCustomQuery', + description: 'Action to run the query for', + }, + query: { + type: 'string', + default: '', + description: 'Custom Query to execute on the connection', + }, + dynamicAuthConfig: { + type: 'object', + default: {}, + description: 'Dynamic auth config for the connection', + }, + timeout: { + type: 'integer', + default: 120, + description: 'Timeout in seconds for execution of custom query', + }, + sortByColumns: { + type: 'array', + items: { + type: 'string', + }, + default: [], + description: 'Column to sort the results by', + }, + connectorOutputPayload: { + type: 'object', + }, + nextPageToken: { + type: 'string', + }, + 'execute-connector_Response': { + required: ['connectorOutputPayload'], + type: 'object', + properties: { + connectorOutputPayload: { + $ref: '#/components/schemas/connectorOutputPayload', + }, + nextPageToken: { + $ref: '#/components/schemas/nextPageToken', + }, + }, + }, + }, + securitySchemes: { + google_auth: { + type: 'oauth2', + flows: { + implicit: { + authorizationUrl: 'https://accounts.google.com/o/oauth2/auth', + scopes: { + 'https://www.googleapis.com/auth/cloud-platform': + 'Auth for google cloud services', + }, + }, + }, + }, + }, + }, +}; + +const ACTION_EXECUTE_OPERATION = { + post: { + summary: 'TestActionDisplayName', + description: 'Use this tool to execute TestAction INSTR', + operationId: 'test_tool_TestActionDisplayName', + 'x-action': 'TestAction', + 'x-operation': 'EXECUTE_ACTION', + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestActionDisplayName_Request', + }, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/TestActionDisplayName_Response', + }, + }, + }, + }, + }, + }, +}; + +const ACTION_QUERY_OPERATION = { + post: { + summary: 'ExecuteCustomQuery', + description: + 'Use this tool to execute ExecuteCustomQuery Use pageSize = 50 and timeout = 120 until user specifies a different value otherwise. If user provides a query in natural language, convert it to SQL query and then execute it using the tool. INSTR', + operationId: 'tp_ExecuteCustomQuery', + 'x-action': 'ExecuteCustomQuery', + 'x-operation': 'EXECUTE_QUERY', + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ExecuteCustomQuery_Request', + }, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/ExecuteCustomQuery_Response', + }, + }, + }, + }, + }, + }, +}; + +const LIST_OPERATION = { + post: { + summary: 'List Issues', + description: + "Returns the list of Issues data. If the page token was available in the response, let users know there are more records available. Ask if the user wants to fetch the next page of results. When passing filter use the\n following format: `field_name1='value1' AND field_name2='value2'\n `. INSTR", + 'x-operation': 'LIST_ENTITIES', + 'x-entity': 'Issues', + operationId: 'tp_list_Issues', + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/list_Issues_Request', + }, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + description: + 'Returns a list of Issues of json schema: {"type":"object"}', + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, +}; + +const GET_OPERATION = { + post: { + summary: 'Get Issues', + description: 'Returns the details of the Issues. INSTR', + operationId: 'tp_get_Issues', + 'x-operation': 'GET_ENTITY', + 'x-entity': 'Issues', + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/get_Issues_Request', + }, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + description: 'Returns Issues of json schema: {"type":"object"}', + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, +}; + +const CREATE_OPERATION = { + post: { + summary: 'Creates a new Issues', + description: 'Creates a new Issues. INSTR', + 'x-operation': 'CREATE_ENTITY', + 'x-entity': 'Issues', + operationId: 'tp_create_Issues', + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/create_Issues_Request', + }, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, +}; + +const UPDATE_OPERATION = { + post: { + summary: 'Updates the Issues', + description: 'Updates the Issues. INSTR', + 'x-operation': 'UPDATE_ENTITY', + 'x-entity': 'Issues', + operationId: 'tp_update_Issues', + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/update_Issues_Request', + }, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, +}; + +const DELETE_OPERATION = { + post: { + summary: 'Delete the Issues', + description: 'Deletes the Issues. INSTR', + 'x-operation': 'DELETE_ENTITY', + 'x-entity': 'Issues', + operationId: 'tp_delete_Issues', + requestBody: { + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/delete_Issues_Request', + }, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, +}; + +const CREATE_REQUEST = { + type: 'object', + required: [ + 'connectorInputPayload', + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'entity', + ], + properties: { + connectorInputPayload: { + $ref: '#/components/schemas/connectorInputPayload_Issues', + }, + operation: { + $ref: '#/components/schemas/operation', + }, + connectionName: { + $ref: '#/components/schemas/connectionName', + }, + serviceName: { + $ref: '#/components/schemas/serviceName', + }, + host: { + $ref: '#/components/schemas/host', + }, + entity: { + $ref: '#/components/schemas/entity', + }, + dynamicAuthConfig: { + $ref: '#/components/schemas/dynamicAuthConfig', + }, + }, +}; + +const UPDATE_REQUEST = { + type: 'object', + required: [ + 'connectorInputPayload', + 'entityId', + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'entity', + ], + properties: { + connectorInputPayload: { + $ref: '#/components/schemas/connectorInputPayload_Issues', + }, + entityId: { + $ref: '#/components/schemas/entityId', + }, + operation: { + $ref: '#/components/schemas/operation', + }, + connectionName: { + $ref: '#/components/schemas/connectionName', + }, + serviceName: { + $ref: '#/components/schemas/serviceName', + }, + host: { + $ref: '#/components/schemas/host', + }, + entity: { + $ref: '#/components/schemas/entity', + }, + dynamicAuthConfig: { + $ref: '#/components/schemas/dynamicAuthConfig', + }, + filterClause: { + $ref: '#/components/schemas/filterClause', + }, + }, +}; + +const GET_REQUEST = { + type: 'object', + required: [ + 'entityId', + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'entity', + ], + properties: { + entityId: { + $ref: '#/components/schemas/entityId', + }, + operation: { + $ref: '#/components/schemas/operation', + }, + connectionName: { + $ref: '#/components/schemas/connectionName', + }, + serviceName: { + $ref: '#/components/schemas/serviceName', + }, + host: { + $ref: '#/components/schemas/host', + }, + entity: { + $ref: '#/components/schemas/entity', + }, + dynamicAuthConfig: { + $ref: '#/components/schemas/dynamicAuthConfig', + }, + }, +}; + +const DELETE_REQUEST = { + type: 'object', + required: [ + 'entityId', + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'entity', + ], + properties: { + entityId: { + $ref: '#/components/schemas/entityId', + }, + operation: { + $ref: '#/components/schemas/operation', + }, + connectionName: { + $ref: '#/components/schemas/connectionName', + }, + serviceName: { + $ref: '#/components/schemas/serviceName', + }, + host: { + $ref: '#/components/schemas/host', + }, + entity: { + $ref: '#/components/schemas/entity', + }, + dynamicAuthConfig: { + $ref: '#/components/schemas/dynamicAuthConfig', + }, + filterClause: { + $ref: '#/components/schemas/filterClause', + }, + }, +}; + +const LIST_REQUEST = { + type: 'object', + required: ['operation', 'connectionName', 'serviceName', 'host', 'entity'], + properties: { + filterClause: { + $ref: '#/components/schemas/filterClause', + }, + pageSize: { + $ref: '#/components/schemas/pageSize', + }, + pageToken: { + $ref: '#/components/schemas/pageToken', + }, + operation: { + $ref: '#/components/schemas/operation', + }, + connectionName: { + $ref: '#/components/schemas/connectionName', + }, + serviceName: { + $ref: '#/components/schemas/serviceName', + }, + host: { + $ref: '#/components/schemas/host', + }, + entity: { + $ref: '#/components/schemas/entity', + }, + sortByColumns: { + $ref: '#/components/schemas/sortByColumns', + }, + dynamicAuthConfig: { + $ref: '#/components/schemas/dynamicAuthConfig', + }, + }, +}; + +const ACTION_REQUEST = { + type: 'object', + required: [ + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'action', + 'connectorInputPayload', + ], + properties: { + operation: { + $ref: '#/components/schemas/operation', + }, + connectionName: { + $ref: '#/components/schemas/connectionName', + }, + serviceName: { + $ref: '#/components/schemas/serviceName', + }, + host: { + $ref: '#/components/schemas/host', + }, + action: { + $ref: '#/components/schemas/action', + }, + connectorInputPayload: { + $ref: '#/components/schemas/connectorInputPayload_TestAction', + }, + dynamicAuthConfig: { + $ref: '#/components/schemas/dynamicAuthConfig', + }, + }, +}; + +const ACTION_RESPONSE = { + type: 'object', + properties: { + connectorOutputPayload: { + $ref: '#/components/schemas/connectorOutputPayload_TestAction', + }, + }, +}; + +const CUSTOM_QUERY_REQUEST = { + type: 'object', + required: [ + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'action', + 'query', + 'timeout', + 'pageSize', + ], + properties: { + operation: { + $ref: '#/components/schemas/operation', + }, + connectionName: { + $ref: '#/components/schemas/connectionName', + }, + serviceName: { + $ref: '#/components/schemas/serviceName', + }, + host: { + $ref: '#/components/schemas/host', + }, + action: { + $ref: '#/components/schemas/action', + }, + query: { + $ref: '#/components/schemas/query', + }, + timeout: { + $ref: '#/components/schemas/timeout', + }, + pageSize: { + $ref: '#/components/schemas/pageSize', + }, + dynamicAuthConfig: { + $ref: '#/components/schemas/dynamicAuthConfig', + }, + }, +}; + +function entityOperation(operation: string) { + const built = buildEntityOperation({ + operation, + entity: 'Issues', + schemaAsString: '{"type":"object"}', + toolName: 'tp', + toolInstructions: 'INSTR', + }); + if (!built) { + expect.fail(`${operation} should be a supported entity operation`); + } + return built; +} + +describe('connector spec builders', () => { + it('builds the connector base spec', () => { + expect(getConnectorBaseSpec()).toEqual(BASE_SPEC); + }); + + it('builds an action operation', () => { + expect( + getActionOperation( + 'TestAction', + 'EXECUTE_ACTION', + 'TestActionDisplayName', + 'test_tool', + 'INSTR', + ), + ).toEqual(ACTION_EXECUTE_OPERATION); + }); + + it('appends custom query guidance to an EXECUTE_QUERY action', () => { + expect( + getActionOperation( + 'ExecuteCustomQuery', + 'EXECUTE_QUERY', + 'ExecuteCustomQuery', + 'tp', + 'INSTR', + ), + ).toEqual(ACTION_QUERY_OPERATION); + }); + + it.each([ + ['LIST', LIST_OPERATION, 'list_Issues_Request', LIST_REQUEST], + ['GET', GET_OPERATION, 'get_Issues_Request', GET_REQUEST], + ['CREATE', CREATE_OPERATION, 'create_Issues_Request', CREATE_REQUEST], + ['UPDATE', UPDATE_OPERATION, 'update_Issues_Request', UPDATE_REQUEST], + ['DELETE', DELETE_OPERATION, 'delete_Issues_Request', DELETE_REQUEST], + ])( + 'builds the %s entity operation and its request schema', + (operation, expectedPath, expectedName, expectedRequest) => { + const built = entityOperation(operation); + + expect(built.path).toEqual(expectedPath); + expect(built.requestSchemaName).toBe(expectedName); + expect(built.requestSchema).toEqual(expectedRequest); + }, + ); + + it('accepts an operation in any case', () => { + expect(entityOperation('list').path).toEqual(LIST_OPERATION); + }); + + it('reports an operation the connector spec cannot express', () => { + expect( + buildEntityOperation({ + operation: 'INVALID', + entity: 'Issues', + schemaAsString: '', + toolName: 'tp', + toolInstructions: '', + }), + ).toBeUndefined(); + }); + + it('builds the action request schema', () => { + expect(actionRequest('TestAction')).toEqual(ACTION_REQUEST); + }); + + it('builds the action response schema', () => { + expect(actionResponse('TestAction')).toEqual(ACTION_RESPONSE); + }); + + it('builds the custom query request schema', () => { + expect(executeCustomQueryRequest()).toEqual(CUSTOM_QUERY_REQUEST); + }); +}); + +describe('convertJsonSchemaToOpenApiSchema', () => { + it('copies a plain type and description', () => { + expect( + convertJsonSchemaToOpenApiSchema({ + type: 'string', + description: 'a string', + }), + ).toEqual({type: 'string', description: 'a string'}); + }); + + it('maps a nullable union onto nullable plus the first concrete type', () => { + expect( + convertJsonSchemaToOpenApiSchema({ + type: ['null', 'string'], + description: 'description', + }), + ).toEqual({type: 'string', nullable: true, description: 'description'}); + }); + + it('marks a null-only type as nullable without a type', () => { + expect(convertJsonSchemaToOpenApiSchema({type: ['null']})).toEqual({ + nullable: true, + }); + }); + + it('takes the first entry of a union without null', () => { + expect( + convertJsonSchemaToOpenApiSchema({type: ['integer', 'string']}), + ).toEqual({type: 'integer'}); + }); + + it('drops keys the connector spec does not use', () => { + expect( + convertJsonSchemaToOpenApiSchema({title: 'ignored', minLength: 3}), + ).toEqual({}); + }); + + it('recurses into object properties', () => { + expect( + convertJsonSchemaToOpenApiSchema({ + type: 'object', + properties: { + input: {type: ['null', 'string'], description: 'description'}, + nested: {type: 'object', properties: {leaf: {type: 'integer'}}}, + }, + }), + ).toEqual({ + type: 'object', + properties: { + input: {type: 'string', nullable: true, description: 'description'}, + nested: {type: 'object', properties: {leaf: {type: 'integer'}}}, + }, + }); + }); + + it('ignores properties that are not objects', () => { + expect( + convertJsonSchemaToOpenApiSchema({ + type: 'object', + properties: {broken: 'not-a-schema'}, + }), + ).toEqual({type: 'object', properties: {broken: {}}}); + }); + + it('leaves an object without properties untouched', () => { + expect(convertJsonSchemaToOpenApiSchema({type: 'object'})).toEqual({ + type: 'object', + }); + }); + + it('recurses into a single array item schema', () => { + expect( + convertJsonSchemaToOpenApiSchema({ + type: 'array', + items: {type: 'string'}, + }), + ).toEqual({type: 'array', items: {type: 'string'}}); + }); + + it('recurses into a list of array item schemas', () => { + expect( + convertJsonSchemaToOpenApiSchema({ + type: 'array', + items: [{type: 'string'}, {type: ['null', 'integer']}], + }), + ).toEqual({ + type: 'array', + items: [{type: 'string'}, {type: 'integer', nullable: true}], + }); + }); +}); diff --git a/core/test/tools/application_integration_tool/clients/integration_client_test.ts b/core/test/tools/application_integration_tool/clients/integration_client_test.ts new file mode 100644 index 000000000..1dc39f569 --- /dev/null +++ b/core/test/tools/application_integration_tool/clients/integration_client_test.ts @@ -0,0 +1,427 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + IntegrationClient, + IntegrationClientOptions, +} from '../../../../src/tools/application_integration_tool/clients/integration_client.js'; + +const PROJECT = 'test-project'; +const LOCATION = 'us-central1'; +const EXECUTE_PATH = `/v2/projects/${PROJECT}/locations/${LOCATION}/integrations/ExecuteConnection:execute?triggerId=api_trigger/ExecuteConnection`; + +const SERVICE_ACCOUNT_JSON = JSON.stringify({ + 'type': 'service_account', + 'project_id': 'dummy', + 'private_key_id': 'dummy-key-id', + 'private_key': 'dummy-private-key', + 'client_email': 'test@example.com', + 'client_id': '131331543646416', + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', + 'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs', + 'client_x509_cert_url': 'https://www.googleapis.com/robot/v1/x509/dummy', + 'universe_domain': 'googleapis.com', +}); + +const {authClient, googleAuthCtor, connectionsClient, connectionsClientCtor} = + vi.hoisted(() => { + const connectionsClient = { + getEntitySchemaAndOperations: vi.fn(), + getActionSchema: vi.fn(), + }; + return { + authClient: { + getAccessToken: vi.fn(), + quotaProjectId: undefined as string | undefined, + }, + googleAuthCtor: vi.fn(), + connectionsClient, + connectionsClientCtor: vi.fn(() => connectionsClient), + }; + }); + +vi.mock('google-auth-library', () => ({ + GoogleAuth: googleAuthCtor, + JWT: vi.fn(), +})); + +vi.mock( + '../../../../src/tools/application_integration_tool/clients/connections_client.js', + () => ({ConnectionsClient: connectionsClientCtor}), +); + +function newClient( + options: Partial = {}, +): IntegrationClient { + return new IntegrationClient({ + project: PROJECT, + location: LOCATION, + ...options, + }); +} + +function schemaNames(spec: OpenAPIV3.Document): string[] { + return Object.keys(spec.components?.schemas ?? {}); +} + +describe('IntegrationClient', () => { + beforeEach(() => { + authClient.quotaProjectId = undefined; + authClient.getAccessToken.mockResolvedValue({token: 'test_token'}); + googleAuthCtor.mockImplementation(() => ({ + getClient: async () => authClient, + })); + connectionsClient.getEntitySchemaAndOperations.mockResolvedValue({ + schema: {type: 'object'}, + operations: ['LIST'], + }); + connectionsClient.getActionSchema.mockResolvedValue({ + inputSchema: {type: 'object'}, + outputSchema: {type: 'string'}, + description: 'Test action', + displayName: 'TestAction', + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + googleAuthCtor.mockReset(); + authClient.getAccessToken.mockReset(); + connectionsClientCtor.mockClear(); + connectionsClient.getEntitySchemaAndOperations.mockReset(); + connectionsClient.getActionSchema.mockReset(); + }); + + describe('getOpenApiSpecForIntegration', () => { + it('posts the trigger resources and decodes the returned spec', async () => { + authClient.quotaProjectId = 'quota-project'; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + openApiSpec: JSON.stringify({openapi: '3.0.0'}), + }), + ), + ); + + const spec = await newClient({ + integration: 'test-integration', + triggers: ['test-trigger'], + }).getOpenApiSpecForIntegration(); + + expect(spec).toEqual({openapi: '3.0.0'}); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + `https://${LOCATION}-integrations.googleapis.com/v1/projects/${PROJECT}/locations/${LOCATION}:generateOpenApiSpec`, + ); + expect(init?.method).toBe('POST'); + expect(init?.headers).toEqual({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test_token', + 'x-goog-user-project': 'quota-project', + }); + expect(init?.body).toBe( + JSON.stringify({ + apiTriggerResources: [ + { + integrationResource: 'test-integration', + triggerId: ['test-trigger'], + }, + ], + fileFormat: 'JSON', + }), + ); + }); + + it('bills the configured project when the credentials name no quota project', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({openApiSpec: '{}'}))); + + await newClient({ + integration: 'test-integration', + }).getOpenApiSpecForIntegration(); + + const [, init] = fetchMock.mock.calls[0]; + expect(init?.headers).toMatchObject({'x-goog-user-project': PROJECT}); + }); + + it('omits the quota project header for an explicit service account', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({openApiSpec: '{}'}))); + + await newClient({ + integration: 'test-integration', + serviceAccountJson: SERVICE_ACCOUNT_JSON, + }).getOpenApiSpecForIntegration(); + + const [, init] = fetchMock.mock.calls[0]; + expect(init?.headers).toEqual({ + 'Content-Type': 'application/json', + 'Authorization': 'Bearer test_token', + }); + }); + + it('returns an empty document when the response carries no spec', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({})), + ); + + await expect( + newClient({integration: 'x'}).getOpenApiSpecForIntegration(), + ).resolves.toEqual({}); + }); + + it('names the integration in the invalid request message', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Bad Request', {status: 400}), + ); + + await expect( + newClient({ + integration: 'test-integration', + }).getOpenApiSpecForIntegration(), + ).rejects.toThrow( + `Invalid request. Please check the provided values of project(${PROJECT}),` + + ` location(${LOCATION}), integration(test-integration).`, + ); + }); + + it('reports other failing statuses as request errors', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Server Error', {status: 500}), + ); + + await expect( + newClient({integration: 'x'}).getOpenApiSpecForIntegration(), + ).rejects.toThrow('Request error: 500 Server Error'); + }); + + it('reports a transport failure as an unexpected error', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline')); + + await expect( + newClient({integration: 'x'}).getOpenApiSpecForIntegration(), + ).rejects.toThrow('An unexpected error occurred: offline'); + }); + + it('reports a credentials failure', async () => { + authClient.getAccessToken.mockRejectedValue(new Error('bad key')); + + await expect( + newClient({ + integration: 'x', + serviceAccountJson: SERVICE_ACCOUNT_JSON, + }).getOpenApiSpecForIntegration(), + ).rejects.toThrow('Credentials error: bad key'); + }); + }); + + describe('getOpenApiSpecForConnection', () => { + it('rejects a connection with neither entity operations nor actions', async () => { + await expect( + newClient({ + connection: 'test-connection', + }).getOpenApiSpecForConnection(), + ).rejects.toThrow( + 'No entity operations or actions provided. Please provide at least one' + + ' of them.', + ); + }); + + it('publishes the requested operations of an entity', async () => { + const spec = await newClient({ + connection: 'test-connection', + entityOperations: {entity1: ['LIST', 'GET']}, + }).getOpenApiSpecForConnection('tp', 'INSTR'); + + expect(Object.keys(spec.paths)).toEqual([ + `${EXECUTE_PATH}#list_entity1`, + `${EXECUTE_PATH}#get_entity1`, + ]); + expect(schemaNames(spec)).toEqual( + expect.arrayContaining([ + 'connectorInputPayload_entity1', + 'list_entity1_Request', + 'get_entity1_Request', + ]), + ); + // The entity's JSON schema is converted to an OpenAPI payload schema. + expect( + spec.components?.schemas?.['connectorInputPayload_entity1'], + ).toEqual({type: 'object'}); + expect(connectionsClientCtor).toHaveBeenCalledWith({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + serviceAccountJson: undefined, + }); + }); + + it('falls back to the operations the connector reports', async () => { + connectionsClient.getEntitySchemaAndOperations.mockResolvedValue({ + schema: {}, + operations: ['CREATE', 'UPDATE', 'DELETE'], + }); + + const spec = await newClient({ + connection: 'test-connection', + entityOperations: {entity1: []}, + }).getOpenApiSpecForConnection(); + + expect(Object.keys(spec.paths)).toEqual([ + `${EXECUTE_PATH}#create_entity1`, + `${EXECUTE_PATH}#update_entity1`, + `${EXECUTE_PATH}#delete_entity1`, + ]); + expect(schemaNames(spec)).toEqual( + expect.arrayContaining([ + 'create_entity1_Request', + 'update_entity1_Request', + 'delete_entity1_Request', + ]), + ); + }); + + it('registers a path and request schema for every supported operation', async () => { + const spec = await newClient({ + connection: 'test-connection', + entityOperations: { + entity1: ['CREATE', 'UPDATE', 'DELETE', 'LIST', 'GET'], + }, + }).getOpenApiSpecForConnection(); + + expect(Object.keys(spec.paths)).toHaveLength(5); + for (const operation of ['create', 'update', 'delete', 'list', 'get']) { + expect( + spec.paths[`${EXECUTE_PATH}#${operation}_entity1`], + ).toBeDefined(); + expect( + spec.components?.schemas?.[`${operation}_entity1_Request`], + ).toBeDefined(); + } + }); + + it('rejects an operation the connector spec cannot express', async () => { + await expect( + newClient({ + connection: 'test-connection', + entityOperations: {entity1: ['INVALID']}, + }).getOpenApiSpecForConnection(), + ).rejects.toThrow('Invalid operation: INVALID for entity: entity1'); + }); + + it('publishes an action with its request and response payloads', async () => { + const spec = await newClient({ + connection: 'test-connection', + actions: ['TestAction'], + }).getOpenApiSpecForConnection(); + + expect(Object.keys(spec.paths)).toEqual([`${EXECUTE_PATH}#TestAction`]); + expect(schemaNames(spec)).toEqual( + expect.arrayContaining([ + 'TestAction_Request', + 'TestAction_Response', + 'connectorInputPayload_TestAction', + 'connectorOutputPayload_TestAction', + ]), + ); + }); + + it('publishes ExecuteCustomQuery as a query action without an input payload', async () => { + connectionsClient.getActionSchema.mockResolvedValue({ + inputSchema: {}, + outputSchema: {}, + description: '', + displayName: 'ExecuteCustomQuery', + }); + + const spec = await newClient({ + connection: 'test-connection', + actions: ['ExecuteCustomQuery'], + }).getOpenApiSpecForConnection(); + + expect(spec.paths[`${EXECUTE_PATH}#ExecuteCustomQuery`]).toMatchObject({ + post: { + 'x-operation': 'EXECUTE_QUERY', + 'x-action': 'ExecuteCustomQuery', + }, + }); + expect(schemaNames(spec)).not.toContain( + 'connectorInputPayload_ExecuteCustomQuery', + ); + expect( + spec.components?.schemas?.['ExecuteCustomQuery_Request'], + ).toMatchObject({required: expect.arrayContaining(['query', 'timeout'])}); + }); + + it('strips spaces from an action display name', async () => { + connectionsClient.getActionSchema.mockResolvedValue({ + inputSchema: {}, + outputSchema: {}, + description: '', + displayName: 'Test Action Name', + }); + + const spec = await newClient({ + connection: 'test-connection', + actions: ['TestAction'], + }).getOpenApiSpecForConnection(); + + expect(schemaNames(spec)).toEqual( + expect.arrayContaining([ + 'TestActionName_Request', + 'TestActionName_Response', + 'connectorInputPayload_TestActionName', + 'connectorOutputPayload_TestActionName', + ]), + ); + }); + + it('honours a connection template override in every generated path', async () => { + const spec = await newClient({ + connection: 'test-connection', + connectionTemplateOverride: 'CustomConnection', + entityOperations: {entity1: ['LIST']}, + actions: ['TestAction'], + }).getOpenApiSpecForConnection(); + + const override = `/v2/projects/${PROJECT}/locations/${LOCATION}/integrations/CustomConnection:execute?triggerId=api_trigger/CustomConnection`; + expect(Object.keys(spec.paths)).toEqual([ + `${override}#list_entity1`, + `${override}#TestAction`, + ]); + }); + + it('defaults an unset connection to the empty name', async () => { + await newClient({ + entityOperations: {entity1: ['LIST']}, + }).getOpenApiSpecForConnection(); + + expect(connectionsClientCtor).toHaveBeenCalledWith( + expect.objectContaining({connection: ''}), + ); + }); + + it('passes the service account through to the connections client', async () => { + await newClient({ + connection: 'test-connection', + serviceAccountJson: SERVICE_ACCOUNT_JSON, + entityOperations: {entity1: ['LIST']}, + }).getOpenApiSpecForConnection(); + + expect(connectionsClientCtor).toHaveBeenCalledWith({ + project: PROJECT, + location: LOCATION, + connection: 'test-connection', + serviceAccountJson: SERVICE_ACCOUNT_JSON, + }); + }); + }); +}); diff --git a/core/test/tools/openapi_tool/rest_api_tool_test.ts b/core/test/tools/openapi_tool/rest_api_tool_test.ts index 974e1aa63..285df4fa6 100644 --- a/core/test/tools/openapi_tool/rest_api_tool_test.ts +++ b/core/test/tools/openapi_tool/rest_api_tool_test.ts @@ -744,6 +744,35 @@ describe('RestApiTool Utilities', () => { expect(result.url).toBe('http://api.example.com/users/123/posts'); expect(result.headers).toEqual({}); }); + + it('drops a path fragment instead of folding it into the query', () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/pipelines:execute?triggerId=api_trigger/Run#list_Issues', + method: 'POST', + }; + + const result = prepareRequestParams(endpoint, [], {}); + + expect(result.url).toBe( + 'http://api.example.com/pipelines:execute?triggerId=api_trigger%2FRun', + ); + expect(new URL(result.url).searchParams.get('triggerId')).toBe( + 'api_trigger/Run', + ); + }); + + it('drops a path fragment that carries no query string', () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/pipelines:execute#list_Issues', + method: 'POST', + }; + + const result = prepareRequestParams(endpoint, [], {}); + + expect(result.url).toBe('http://api.example.com/pipelines:execute'); + }); }); describe('prepareRequestBody', () => { diff --git a/core/test/utils/error_utils_test.ts b/core/test/utils/error_utils_test.ts new file mode 100644 index 000000000..d94783ead --- /dev/null +++ b/core/test/utils/error_utils_test.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import {toMessage} from '../../src/utils/error_utils.js'; + +describe('toMessage', () => { + it('uses the message of an Error', () => { + expect(toMessage(new Error('boom'))).toBe('boom'); + }); + + it('stringifies a non-Error value', () => { + expect(toMessage({code: 7})).toBe('[object Object]'); + }); +}); diff --git a/core/test/utils/service_account_utils_test.ts b/core/test/utils/service_account_utils_test.ts new file mode 100644 index 000000000..328b92212 --- /dev/null +++ b/core/test/utils/service_account_utils_test.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import {parseServiceAccountCredential} from '../../src/utils/service_account_utils.js'; + +const KEY_FILE = { + 'type': 'service_account', + 'project_id': 'dummy', + 'private_key_id': 'dummy-key-id', + 'private_key': 'dummy-private-key', + 'client_email': 'test@example.com', + 'client_id': '131331543646416', + 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', + 'token_uri': 'https://oauth2.googleapis.com/token', + 'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs', + 'client_x509_cert_url': + 'https://www.googleapis.com/robot/v1/metadata/x509/dummy%40dummy.com', + 'universe_domain': 'googleapis.com', +}; + +describe('parseServiceAccountCredential', () => { + it('maps every snake_case key file field onto the camelCase credential', () => { + expect(parseServiceAccountCredential(JSON.stringify(KEY_FILE))).toEqual({ + type: 'service_account', + projectId: 'dummy', + privateKeyId: 'dummy-key-id', + privateKey: 'dummy-private-key', + clientEmail: 'test@example.com', + clientId: '131331543646416', + authUri: 'https://accounts.google.com/o/oauth2/auth', + tokenUri: 'https://oauth2.googleapis.com/token', + authProviderX509CertUrl: 'https://www.googleapis.com/oauth2/v1/certs', + clientX509CertUrl: + 'https://www.googleapis.com/robot/v1/metadata/x509/dummy%40dummy.com', + universeDomain: 'googleapis.com', + }); + }); + + it('rejects a string that is not JSON and keeps the parse failure', () => { + expect(() => parseServiceAccountCredential('not json')).toThrow( + 'Invalid service account JSON.', + ); + try { + parseServiceAccountCredential('not json'); + expect.fail('parsing invalid JSON should throw'); + } catch (err: unknown) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).cause).toBeInstanceOf(SyntaxError); + } + }); + + it('rejects JSON that is not an object', () => { + expect(() => parseServiceAccountCredential('"a string"')).toThrow( + 'Invalid service account JSON: expected a service account key file.', + ); + }); + + it('rejects a key file with the wrong type', () => { + expect(() => + parseServiceAccountCredential( + JSON.stringify({...KEY_FILE, type: 'authorized_user'}), + ), + ).toThrow( + 'Invalid service account JSON: expected a service account key file.', + ); + }); + + it('rejects a key file that is missing a required field', () => { + const {private_key: _privateKey, ...incomplete} = KEY_FILE; + expect(() => + parseServiceAccountCredential(JSON.stringify(incomplete)), + ).toThrow( + 'Invalid service account JSON: expected a service account key file.', + ); + }); + + it('rejects a key file whose field has the wrong type', () => { + expect(() => + parseServiceAccountCredential( + JSON.stringify({...KEY_FILE, client_id: 12345}), + ), + ).toThrow( + 'Invalid service account JSON: expected a service account key file.', + ); + }); +});