From 5c0fbf52c499e58971f5f58f8e586a95f497a913 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 08:57:46 -0700 Subject: [PATCH 1/5] Feat: add authenticated Google API request plumbing for connector clients The Application Integration and Integration Connectors clients both need an access token (from ADC or an explicit service-account key file) and the same mapping from transport/status failures onto user-facing messages. Extract that once so neither client reimplements it. Token caching is left to google-auth-library rather than hand-rolled, and every request carries an AbortSignal timeout. --- .../clients/api_request.ts | 141 +++++++++ core/src/utils/service_account_utils.ts | 65 ++++ .../clients/api_request_test.ts | 282 ++++++++++++++++++ core/test/utils/service_account_utils_test.ts | 83 ++++++ 4 files changed, 571 insertions(+) create mode 100644 core/src/tools/application_integration_tool/clients/api_request.ts create mode 100644 core/src/utils/service_account_utils.ts create mode 100644 core/test/tools/application_integration_tool/clients/api_request_test.ts create mode 100644 core/test/utils/service_account_utils_test.ts 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..c33868776 --- /dev/null +++ b/core/src/tools/application_integration_tool/clients/api_request.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AnyAuthClient, GoogleAuth, JWT} from 'google-auth-library'; +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.'; + +/** Narrows an unknown thrown value to a human-readable message. */ +export function toMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * 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) { + if (this.hasExplicitServiceAccount) { + throw new Error(`Credentials error: ${toMessage(err)}`); + } + throw new Error(MISSING_CREDENTIALS_MESSAGE); + } + if (!token) { + throw new Error(MISSING_CREDENTIALS_MESSAGE); + } + return token; + } + + /** + * The billing/quota project advertised by the resolved credentials, if any. + * Only populated once {@link getAccessToken} has resolved a client. + */ + async getQuotaProjectId(): Promise { + const client = await this.auth.getClient(); + return client.quotaProjectId; + } +} + +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/utils/service_account_utils.ts b/core/src/utils/service_account_utils.ts new file mode 100644 index 000000000..aa7033ff6 --- /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. + * @returns The parsed credential. + */ +export function parseServiceAccountCredential( + serviceAccountJson: string, +): ServiceAccountCredential { + let parsed: unknown; + try { + parsed = JSON.parse(serviceAccountJson); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid service account JSON: ${message}`); + } + + 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..55b92383d --- /dev/null +++ b/core/test/tools/application_integration_tool/clients/api_request_test.ts @@ -0,0 +1,282 @@ +/** + * @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, + toMessage, +} 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('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(); + }); +}); + +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..7b840a79d --- /dev/null +++ b/core/test/utils/service_account_utils_test.ts @@ -0,0 +1,83 @@ +/** + * @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', () => { + expect(() => parseServiceAccountCredential('not json')).toThrow( + /^Invalid service account JSON: /, + ); + }); + + 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.', + ); + }); +}); From 270a03c2194a5844715bb374932f1d1a10510676 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 08:57:54 -0700 Subject: [PATCH 2/5] Feat: port ConnectionsClient and IntegrationClient from adk-python Generates the OpenAPI spec for an Application Integration trigger, or for an Integration Connector connection's entity operations and actions. Wire strings (paths, $refs, schema defaults, descriptions) are byte-identical to the Python reference so the generated spec stays interchangeable. The long-running-operation poll is bounded instead of looping forever, and it only sleeps between attempts. --- core/src/common.ts | 12 + .../clients/connections_client.ts | 255 ++++++ .../clients/connector_spec_builders.ts | 593 ++++++++++++++ .../clients/integration_client.ts | 273 +++++++ core/src/utils/service_account_utils.ts | 4 +- .../clients/connections_client_test.ts | 349 ++++++++ .../clients/connector_spec_builders_test.ts | 753 ++++++++++++++++++ .../clients/integration_client_test.ts | 431 ++++++++++ core/test/utils/service_account_utils_test.ts | 11 +- 9 files changed, 2677 insertions(+), 4 deletions(-) create mode 100644 core/src/tools/application_integration_tool/clients/connections_client.ts create mode 100644 core/src/tools/application_integration_tool/clients/connector_spec_builders.ts create mode 100644 core/src/tools/application_integration_tool/clients/integration_client.ts create mode 100644 core/test/tools/application_integration_tool/clients/connections_client_test.ts create mode 100644 core/test/tools/application_integration_tool/clients/connector_spec_builders_test.ts create mode 100644 core/test/tools/application_integration_tool/clients/integration_client_test.ts diff --git a/core/src/common.ts b/core/src/common.ts index 23f628165..9ba7de139 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -309,6 +309,18 @@ 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 {ConnectionsClient} from './tools/application_integration_tool/clients/connections_client.js'; +export type { + ActionSchema, + ConnectionDetails, + ConnectionsClientOptions, + EntitySchemaAndOperations, +} from './tools/application_integration_tool/clients/connections_client.js'; +export {IntegrationClient} from './tools/application_integration_tool/clients/integration_client.js'; +export type { + EntityOperations, + IntegrationClientOptions, +} 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/connections_client.ts b/core/src/tools/application_integration_tool/clients/connections_client.ts new file mode 100644 index 000000000..e8ed804f5 --- /dev/null +++ b/core/src/tools/application_integration_tool/clients/connections_client.ts @@ -0,0 +1,255 @@ +/** + * @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 ?? '', + }; + } + + /** Converts a connector entity schema into an OpenAPI payload schema. */ + @experimental + connectorPayload( + jsonSchema: Record, + ): Record { + return convertJsonSchemaToOpenApiSchema(jsonSchema); + } + + 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)); +} + +/** + * 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/connector_spec_builders.ts b/core/src/tools/application_integration_tool/clients/connector_spec_builders.ts new file mode 100644 index 000000000..8432ec4b1 --- /dev/null +++ b/core/src/tools/application_integration_tool/clients/connector_spec_builders.ts @@ -0,0 +1,593 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * 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'; + +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.'; + +/** + * 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}`; +} + +/** + * The connector spec under construction. `paths` and `components.schemas` hold + * generated JSON fragments, so they stay untyped until the finished document + * is handed to the OpenAPI parser. + */ +export type ConnectorSpec = { + openapi: string; + info: {title: string; description: string; version: string}; + servers: Array<{url: string}>; + security: Array>; + paths: Record; + components: { + schemas: Record; + securitySchemes: Record; + }; +}; + +/** 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: { + 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', + }, + }, + }, + }, + }, + }, + }; +} + +/** Path item for executing a connector action. */ +export function getActionOperation( + action: string, + operation: string, + actionDisplayName: string, + toolName = '', + toolInstructions = '', +): Record { + 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: `#/components/schemas/${actionDisplayName}_Request`, + }, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: `#/components/schemas/${actionDisplayName}_Response`, + }, + }, + }, + }, + }, + }, + }; +} + +/** Path item for listing entities. */ +export function listOperation( + entity: string, + schemaAsString = '', + toolName = '', + toolInstructions = '', +): Record { + return { + post: { + summary: `List ${entity}`, + description: listDescription(entity, toolInstructions), + 'x-operation': 'LIST_ENTITIES', + 'x-entity': entity, + operationId: `${toolName}_list_${entity}`, + requestBody: { + content: { + 'application/json': { + schema: {$ref: `#/components/schemas/list_${entity}_Request`}, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + description: `Returns a list of ${entity} of json schema: ${schemaAsString}`, + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, + }; +} + +/** Path item for reading a single entity. */ +export function getOperation( + entity: string, + schemaAsString = '', + toolName = '', + toolInstructions = '', +): Record { + return { + post: { + summary: `Get ${entity}`, + description: `Returns the details of the ${entity}. ${toolInstructions}`, + operationId: `${toolName}_get_${entity}`, + 'x-operation': 'GET_ENTITY', + 'x-entity': entity, + requestBody: { + content: { + 'application/json': { + schema: {$ref: `#/components/schemas/get_${entity}_Request`}, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + description: `Returns ${entity} of json schema: ${schemaAsString}`, + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, + }; +} + +/** Path item for creating an entity. */ +export function createOperation( + entity: string, + toolName = '', + toolInstructions = '', +): Record { + return { + post: { + summary: `Creates a new ${entity}`, + description: `Creates a new ${entity}. ${toolInstructions}`, + 'x-operation': 'CREATE_ENTITY', + 'x-entity': entity, + operationId: `${toolName}_create_${entity}`, + requestBody: { + content: { + 'application/json': { + schema: {$ref: `#/components/schemas/create_${entity}_Request`}, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, + }; +} + +/** Path item for updating an entity. */ +export function updateOperation( + entity: string, + toolName = '', + toolInstructions = '', +): Record { + return { + post: { + summary: `Updates the ${entity}`, + description: `Updates the ${entity}. ${toolInstructions}`, + 'x-operation': 'UPDATE_ENTITY', + 'x-entity': entity, + operationId: `${toolName}_update_${entity}`, + requestBody: { + content: { + 'application/json': { + schema: {$ref: `#/components/schemas/update_${entity}_Request`}, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, + }; +} + +/** Path item for deleting an entity. */ +export function deleteOperation( + entity: string, + toolName = '', + toolInstructions = '', +): Record { + return { + post: { + summary: `Delete the ${entity}`, + description: `Deletes the ${entity}. ${toolInstructions}`, + 'x-operation': 'DELETE_ENTITY', + 'x-entity': entity, + operationId: `${toolName}_delete_${entity}`, + requestBody: { + content: { + 'application/json': { + schema: {$ref: `#/components/schemas/delete_${entity}_Request`}, + }, + }, + }, + responses: { + '200': { + description: 'Success response', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/execute-connector_Response', + }, + }, + }, + }, + }, + }, + }; +} + +/** Request schema for the create operation. */ +export function createOperationRequest( + entity: string, +): Record { + return { + type: 'object', + required: [ + 'connectorInputPayload', + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'entity', + ], + properties: { + connectorInputPayload: { + $ref: `#/components/schemas/connectorInputPayload_${entity}`, + }, + 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'}, + }, + }; +} + +/** Request schema for the update operation. */ +export function updateOperationRequest( + entity: string, +): Record { + return { + type: 'object', + required: [ + 'connectorInputPayload', + 'entityId', + 'operation', + 'connectionName', + 'serviceName', + 'host', + 'entity', + ], + properties: { + connectorInputPayload: { + $ref: `#/components/schemas/connectorInputPayload_${entity}`, + }, + 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'}, + }, + }; +} + +/** Request schema for the get operation. */ +export function getOperationRequest(): Record { + return { + 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'}, + }, + }; +} + +/** Request schema for the delete operation. */ +export function deleteOperationRequest(): Record { + return { + 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'}, + }, + }; +} + +/** Request schema for the list operation. */ +export function listOperationRequest(): Record { + return { + 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'}, + }, + }; +} + +/** Request schema for executing an action. */ +export function actionRequest(action: string): Record { + return { + 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_${action}`, + }, + dynamicAuthConfig: {$ref: '#/components/schemas/dynamicAuthConfig'}, + }, + }; +} + +/** Response schema for executing an action. */ +export function actionResponse(action: string): Record { + return { + type: 'object', + properties: { + connectorOutputPayload: { + $ref: `#/components/schemas/connectorOutputPayload_${action}`, + }, + }, + }; +} + +/** Request schema for the built-in custom-query action. */ +export function executeCustomQueryRequest(): Record { + return { + 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'}, + }, + }; +} 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..38255a227 --- /dev/null +++ b/core/src/tools/application_integration_tool/clients/integration_client.ts @@ -0,0 +1,273 @@ +/** + * @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, + ConnectorSpec, + createOperation, + createOperationRequest, + deleteOperation, + deleteOperationRequest, + executeCustomQueryRequest, + getActionOperation, + getConnectorBaseSpec, + getOperation, + getOperationRequest, + listOperation, + listOperationRequest, + updateOperation, + updateOperationRequest, +} 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; + +/** Returns the regional Application Integration host for a location. */ +export function getIntegrationsEndpoint(location: string): string { + return `${location}-integrations.googleapis.com`; +} + +/** 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 connection: string; + private readonly tokenProvider: AccessTokenProvider; + + constructor(options: IntegrationClientOptions) { + this.options = options; + this.connection = options.connection ?? ''; + 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://${getIntegrationsEndpoint(location)}/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.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}`] = + connectionsClient.connectorPayload(schema); + + const schemaAsString = JSON.stringify(schema); + for (const operation of operations.length + ? operations + : supportedOperations) { + addEntityOperation(spec, { + entity, + operation, + schemaAsString, + path: `${executePath}#${operation.toLowerCase()}_${entity}`, + toolName, + toolInstructions, + }); + } + } + + 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}`] = + connectionsClient.connectorPayload(actionDetails.inputSchema); + } + spec.components.schemas[`connectorOutputPayload_${displayName}`] = + connectionsClient.connectorPayload(actionDetails.outputSchema); + spec.components.schemas[`${displayName}_Response`] = + actionResponse(displayName); + spec.paths[`${executePath}#${action}`] = getActionOperation( + action, + operation, + displayName, + toolName, + toolInstructions, + ); + } + + // The generated fragments form an OpenAPI document once assembled. + return spec as OpenAPIV3.Document; + } +} + +interface EntityOperationRequest { + entity: string; + operation: string; + schemaAsString: string; + path: string; + toolName: string; + toolInstructions: string; +} + +function addEntityOperation( + spec: ConnectorSpec, + request: EntityOperationRequest, +): void { + const {entity, path, schemaAsString, toolName, toolInstructions} = request; + const {paths, components} = spec; + + switch (request.operation.toLowerCase()) { + case 'create': + paths[path] = createOperation(entity, toolName, toolInstructions); + components.schemas[`create_${entity}_Request`] = + createOperationRequest(entity); + return; + case 'update': + paths[path] = updateOperation(entity, toolName, toolInstructions); + components.schemas[`update_${entity}_Request`] = + updateOperationRequest(entity); + return; + case 'delete': + paths[path] = deleteOperation(entity, toolName, toolInstructions); + components.schemas[`delete_${entity}_Request`] = deleteOperationRequest(); + return; + case 'list': + paths[path] = listOperation( + entity, + schemaAsString, + toolName, + toolInstructions, + ); + components.schemas[`list_${entity}_Request`] = listOperationRequest(); + return; + case 'get': + paths[path] = getOperation( + entity, + schemaAsString, + toolName, + toolInstructions, + ); + components.schemas[`get_${entity}_Request`] = getOperationRequest(); + return; + default: + throw new Error( + `Invalid operation: ${request.operation} for entity: ${entity}`, + ); + } +} + +function hasEntries(value?: EntityOperations): boolean { + return value !== undefined && Object.keys(value).length > 0; +} diff --git a/core/src/utils/service_account_utils.ts b/core/src/utils/service_account_utils.ts index aa7033ff6..c1e38f8ba 100644 --- a/core/src/utils/service_account_utils.ts +++ b/core/src/utils/service_account_utils.ts @@ -42,6 +42,7 @@ function isServiceAccountCredential( * * @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( @@ -51,8 +52,7 @@ export function parseServiceAccountCredential( try { parsed = JSON.parse(serviceAccountJson); } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`Invalid service account JSON: ${message}`); + throw new Error('Invalid service account JSON.', {cause: err}); } const credential = camelCaseKeys(parsed); 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..12248a2ca --- /dev/null +++ b/core/test/tools/application_integration_tool/clients/connections_client_test.ts @@ -0,0 +1,349 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import { + ConnectionsClient, + convertJsonSchemaToOpenApiSchema, + 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; + }); + + it('exposes the payload converter as a client method', () => { + expect(newClient().connectorPayload({type: 'string'})).toEqual({ + type: 'string', + }); + }); +}); + +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/connector_spec_builders_test.ts b/core/test/tools/application_integration_tool/clients/connector_spec_builders_test.ts new file mode 100644 index 000000000..62fa32024 --- /dev/null +++ b/core/test/tools/application_integration_tool/clients/connector_spec_builders_test.ts @@ -0,0 +1,753 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import { + actionRequest, + actionResponse, + createOperation, + createOperationRequest, + deleteOperation, + deleteOperationRequest, + executeCustomQueryRequest, + getActionOperation, + getConnectorBaseSpec, + getOperation, + getOperationRequest, + listOperation, + listOperationRequest, + updateOperation, + updateOperationRequest, +} 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', + }, + }, +}; + +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('builds a list operation', () => { + expect(listOperation('Issues', '{"type":"object"}', 'tp', 'INSTR')).toEqual( + LIST_OPERATION, + ); + }); + + it('builds a get operation', () => { + expect(getOperation('Issues', '{"type":"object"}', 'tp', 'INSTR')).toEqual( + GET_OPERATION, + ); + }); + + it('builds a create operation', () => { + expect(createOperation('Issues', 'tp', 'INSTR')).toEqual(CREATE_OPERATION); + }); + + it('builds an update operation', () => { + expect(updateOperation('Issues', 'tp', 'INSTR')).toEqual(UPDATE_OPERATION); + }); + + it('builds a delete operation', () => { + expect(deleteOperation('Issues', 'tp', 'INSTR')).toEqual(DELETE_OPERATION); + }); + + it('builds the create request schema', () => { + expect(createOperationRequest('Issues')).toEqual(CREATE_REQUEST); + }); + + it('builds the update request schema', () => { + expect(updateOperationRequest('Issues')).toEqual(UPDATE_REQUEST); + }); + + it('builds the get request schema', () => { + expect(getOperationRequest()).toEqual(GET_REQUEST); + }); + + it('builds the delete request schema', () => { + expect(deleteOperationRequest()).toEqual(DELETE_REQUEST); + }); + + it('builds the list request schema', () => { + expect(listOperationRequest()).toEqual(LIST_REQUEST); + }); + + 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); + }); + + it('defaults the tool name and instructions to empty strings', () => { + const operation = listOperation('Issues'); + expect(operation).toEqual({ + post: expect.objectContaining({ + operationId: '_list_Issues', + description: expect.stringContaining('`. '), + }), + }); + }); +}); 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..58c98f85e --- /dev/null +++ b/core/test/tools/application_integration_tool/clients/integration_client_test.ts @@ -0,0 +1,431 @@ +/** + * @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(), + connectorPayload: 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', + }); + connectionsClient.connectorPayload.mockImplementation( + (schema: unknown) => ({converted: schema}), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + googleAuthCtor.mockReset(); + authClient.getAccessToken.mockReset(); + connectionsClientCtor.mockClear(); + connectionsClient.getEntitySchemaAndOperations.mockReset(); + connectionsClient.getActionSchema.mockReset(); + connectionsClient.connectorPayload.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', + ]), + ); + expect( + spec.components?.schemas?.['connectorInputPayload_entity1'], + ).toEqual({converted: {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/utils/service_account_utils_test.ts b/core/test/utils/service_account_utils_test.ts index 7b840a79d..328b92212 100644 --- a/core/test/utils/service_account_utils_test.ts +++ b/core/test/utils/service_account_utils_test.ts @@ -40,10 +40,17 @@ describe('parseServiceAccountCredential', () => { }); }); - it('rejects a string that is not JSON', () => { + it('rejects a string that is not JSON and keeps the parse failure', () => { expect(() => parseServiceAccountCredential('not json')).toThrow( - /^Invalid service account JSON: /, + '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', () => { From d226f727d97bce29368d08e7e4f67f70eadfbfca Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 09:21:53 -0700 Subject: [PATCH 3/5] Fix: report a missing-credentials failure from the quota project lookup getQuotaProjectId() resolves an auth client of its own, so an unavailable ADC surfaced the raw google-auth error instead of the message that asks the caller for a service account. --- .../clients/api_request.ts | 21 ++++++++++++------- .../clients/api_request_test.ts | 17 +++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/core/src/tools/application_integration_tool/clients/api_request.ts b/core/src/tools/application_integration_tool/clients/api_request.ts index c33868776..bcbe4ec68 100644 --- a/core/src/tools/application_integration_tool/clients/api_request.ts +++ b/core/src/tools/application_integration_tool/clients/api_request.ts @@ -56,10 +56,7 @@ export class AccessTokenProvider { const client = await this.auth.getClient(); token = (await client.getAccessToken()).token; } catch (err: unknown) { - if (this.hasExplicitServiceAccount) { - throw new Error(`Credentials error: ${toMessage(err)}`); - } - throw new Error(MISSING_CREDENTIALS_MESSAGE); + throw this.credentialsError(err); } if (!token) { throw new Error(MISSING_CREDENTIALS_MESSAGE); @@ -69,11 +66,21 @@ export class AccessTokenProvider { /** * The billing/quota project advertised by the resolved credentials, if any. - * Only populated once {@link getAccessToken} has resolved a client. + * + * @throws {Error} If no usable credentials are available. */ async getQuotaProjectId(): Promise { - const client = await this.auth.getClient(); - return client.quotaProjectId; + 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); } } 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 index 55b92383d..96243f9b6 100644 --- a/core/test/tools/application_integration_tool/clients/api_request_test.ts +++ b/core/test/tools/application_integration_tool/clients/api_request_test.ts @@ -130,6 +130,23 @@ describe('AccessTokenProvider', () => { ); }); + 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(); From 0c423dfb572c64bf02aa2fcea3ed6f66ff759d59 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 10:17:26 -0700 Subject: [PATCH 4/5] Fix: drop the URL fragment from a generated path instead of encoding it into the query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Integration Connector spec appends `#_` to the shared `:execute` endpoint so several operations can occupy distinct spec paths. prepareRequestParams split the path on '?' and handed the remainder — fragment included — to URLSearchParams, which percent-encoded it, so the request went out with triggerId=api_trigger%2FExecuteConnection%23list_Issues and named a trigger that does not exist. adk-python drops the fragment before rebuilding the URL (rest_api_tool.py: urlunparse(parsed._replace(query='', fragment=''))); do the same here. --- core/src/tools/openapi_tool/rest_api_tool.ts | 6 ++++ .../tools/openapi_tool/rest_api_tool_test.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) 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/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', () => { From b183bf836cb90397e79b43c499cb3e98db7dd459 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 3 Aug 2026 10:39:07 -0700 Subject: [PATCH 5/5] Refactor: collapse the connector spec builders onto a table and type them strictly The five entity path-item builders and the five request-schema builders shared one body each and differed only in a verb, a summary, a description and one $ref slug, so they become a single ENTITY_OPERATIONS table plus one buildEntityOperation(); the five-arm switch in IntegrationClient collapses into a lookup that keeps the parity error message. A refs() helper replaces the $ref literal repeated once per field. The fragments are now typed as OpenAPIV3 path items and schemas, which removes the 'as OpenAPIV3.Document' cast that laundered a deliberately untyped value into a strict one. The x-* extension interface moves here, where it is produced, instead of being redeclared by the consumer. Also drops ConnectionsClient.connectorPayload, a pass-through to the exported converter that never touched 'this' (the converter moves next to the other spec-shaping functions), inlines three one-use indirections, makes the tool name and instruction parameters required since every caller passes them, and moves toMessage to core/src/utils/error_utils.ts, where the same expression is hand-rolled in nine other places. Byte parity with the Python reference is unchanged: every builder's output was re-diffed against connections_client.py after the refactor. The test expectations are the same Python-derived literals, reached through the new entry point. --- core/src/common.ts | 13 +- .../clients/api_request.ts | 6 +- .../clients/connections_client.ts | 64 -- .../clients/connector_spec_builders.ts | 667 ++++++++---------- .../clients/integration_client.ts | 100 +-- core/src/utils/error_utils.ts | 15 + .../clients/api_request_test.ts | 11 - .../clients/connections_client_test.ts | 99 --- .../clients/connector_spec_builders_test.ts | 169 +++-- .../clients/integration_client_test.ts | 8 +- core/test/utils/error_utils_test.ts | 18 + 11 files changed, 461 insertions(+), 709 deletions(-) create mode 100644 core/src/utils/error_utils.ts create mode 100644 core/test/utils/error_utils_test.ts diff --git a/core/src/common.ts b/core/src/common.ts index 9ba7de139..64b302c40 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -309,18 +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 {ConnectionsClient} from './tools/application_integration_tool/clients/connections_client.js'; -export type { - ActionSchema, - ConnectionDetails, - ConnectionsClientOptions, - EntitySchemaAndOperations, -} from './tools/application_integration_tool/clients/connections_client.js'; -export {IntegrationClient} from './tools/application_integration_tool/clients/integration_client.js'; -export type { - EntityOperations, - IntegrationClientOptions, -} from './tools/application_integration_tool/clients/integration_client.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 index bcbe4ec68..43b2bdf4c 100644 --- a/core/src/tools/application_integration_tool/clients/api_request.ts +++ b/core/src/tools/application_integration_tool/clients/api_request.ts @@ -5,6 +5,7 @@ */ 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. */ @@ -18,11 +19,6 @@ const MISSING_CREDENTIALS_MESSAGE = 'Please provide a service account that has the required permissions to' + ' access the connection.'; -/** Narrows an unknown thrown value to a human-readable message. */ -export function toMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err); -} - /** * Supplies OAuth2 access tokens for Google API calls, either from an explicit * service-account key file or from Application Default Credentials. diff --git a/core/src/tools/application_integration_tool/clients/connections_client.ts b/core/src/tools/application_integration_tool/clients/connections_client.ts index e8ed804f5..7b4d69373 100644 --- a/core/src/tools/application_integration_tool/clients/connections_client.ts +++ b/core/src/tools/application_integration_tool/clients/connections_client.ts @@ -157,14 +157,6 @@ export class ConnectionsClient { }; } - /** Converts a connector entity schema into an OpenAPI payload schema. */ - @experimental - connectorPayload( - jsonSchema: Record, - ): Record { - return convertJsonSchemaToOpenApiSchema(jsonSchema); - } - private get(url: string): Promise { return executeApiCall({ url, @@ -197,59 +189,3 @@ export class ConnectionsClient { function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } - -/** - * 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/connector_spec_builders.ts b/core/src/tools/application_integration_tool/clients/connector_spec_builders.ts index 8432ec4b1..2c4ab66fd 100644 --- a/core/src/tools/application_integration_tool/clients/connector_spec_builders.ts +++ b/core/src/tools/application_integration_tool/clients/connector_spec_builders.ts @@ -4,6 +4,8 @@ * 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. @@ -16,11 +18,60 @@ /** 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. @@ -31,22 +82,169 @@ function listDescription(entity: string, toolInstructions: string): string { \`. ${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; +} + /** - * The connector spec under construction. `paths` and `components.schemas` hold - * generated JSON fragments, so they stay untyped until the finished document - * is handed to the OpenAPI parser. + * 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 type ConnectorSpec = { - openapi: string; - info: {title: string; description: string; version: string}; - servers: Array<{url: string}>; - security: Array>; - paths: Record; - components: { - schemas: Record; - securitySchemes: Record; +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 { @@ -144,12 +342,7 @@ export function getConnectorBaseSpec(): ConnectorSpec { 'execute-connector_Response': { required: ['connectorOutputPayload'], type: 'object', - properties: { - connectorOutputPayload: { - $ref: '#/components/schemas/connectorOutputPayload', - }, - nextPageToken: {$ref: '#/components/schemas/nextPageToken'}, - }, + properties: refs('connectorOutputPayload', 'nextPageToken'), }, }, securitySchemes: { @@ -175,9 +368,9 @@ export function getActionOperation( action: string, operation: string, actionDisplayName: string, - toolName = '', - toolInstructions = '', -): Record { + toolName: string, + toolInstructions: string, +): ConnectorPathItem { let description = `Use this tool to execute ${action}`; if (operation === 'EXECUTE_QUERY') { description += EXECUTE_QUERY_INSTRUCTIONS; @@ -191,22 +384,14 @@ export function getActionOperation( 'x-operation': operation, requestBody: { content: { - 'application/json': { - schema: { - $ref: `#/components/schemas/${actionDisplayName}_Request`, - }, - }, + 'application/json': {schema: ref(`${actionDisplayName}_Request`)}, }, }, responses: { '200': { description: 'Success response', content: { - 'application/json': { - schema: { - $ref: `#/components/schemas/${actionDisplayName}_Response`, - }, - }, + 'application/json': {schema: ref(`${actionDisplayName}_Response`)}, }, }, }, @@ -214,380 +399,96 @@ export function getActionOperation( }; } -/** Path item for listing entities. */ -export function listOperation( - entity: string, - schemaAsString = '', - toolName = '', - toolInstructions = '', -): Record { - return { - post: { - summary: `List ${entity}`, - description: listDescription(entity, toolInstructions), - 'x-operation': 'LIST_ENTITIES', - 'x-entity': entity, - operationId: `${toolName}_list_${entity}`, - requestBody: { - content: { - 'application/json': { - schema: {$ref: `#/components/schemas/list_${entity}_Request`}, - }, - }, - }, - responses: { - '200': { - description: 'Success response', - content: { - 'application/json': { - schema: { - description: `Returns a list of ${entity} of json schema: ${schemaAsString}`, - $ref: '#/components/schemas/execute-connector_Response', - }, - }, - }, - }, - }, - }, - }; -} - -/** Path item for reading a single entity. */ -export function getOperation( - entity: string, - schemaAsString = '', - toolName = '', - toolInstructions = '', -): Record { - return { - post: { - summary: `Get ${entity}`, - description: `Returns the details of the ${entity}. ${toolInstructions}`, - operationId: `${toolName}_get_${entity}`, - 'x-operation': 'GET_ENTITY', - 'x-entity': entity, - requestBody: { - content: { - 'application/json': { - schema: {$ref: `#/components/schemas/get_${entity}_Request`}, - }, - }, - }, - responses: { - '200': { - description: 'Success response', - content: { - 'application/json': { - schema: { - description: `Returns ${entity} of json schema: ${schemaAsString}`, - $ref: '#/components/schemas/execute-connector_Response', - }, - }, - }, - }, - }, - }, - }; -} - -/** Path item for creating an entity. */ -export function createOperation( - entity: string, - toolName = '', - toolInstructions = '', -): Record { - return { - post: { - summary: `Creates a new ${entity}`, - description: `Creates a new ${entity}. ${toolInstructions}`, - 'x-operation': 'CREATE_ENTITY', - 'x-entity': entity, - operationId: `${toolName}_create_${entity}`, - requestBody: { - content: { - 'application/json': { - schema: {$ref: `#/components/schemas/create_${entity}_Request`}, - }, - }, - }, - responses: { - '200': { - description: 'Success response', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/execute-connector_Response', - }, - }, - }, - }, - }, - }, - }; -} - -/** Path item for updating an entity. */ -export function updateOperation( - entity: string, - toolName = '', - toolInstructions = '', -): Record { - return { - post: { - summary: `Updates the ${entity}`, - description: `Updates the ${entity}. ${toolInstructions}`, - 'x-operation': 'UPDATE_ENTITY', - 'x-entity': entity, - operationId: `${toolName}_update_${entity}`, - requestBody: { - content: { - 'application/json': { - schema: {$ref: `#/components/schemas/update_${entity}_Request`}, - }, - }, - }, - responses: { - '200': { - description: 'Success response', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/execute-connector_Response', - }, - }, - }, - }, - }, - }, - }; -} - -/** Path item for deleting an entity. */ -export function deleteOperation( - entity: string, - toolName = '', - toolInstructions = '', -): Record { - return { - post: { - summary: `Delete the ${entity}`, - description: `Deletes the ${entity}. ${toolInstructions}`, - 'x-operation': 'DELETE_ENTITY', - 'x-entity': entity, - operationId: `${toolName}_delete_${entity}`, - requestBody: { - content: { - 'application/json': { - schema: {$ref: `#/components/schemas/delete_${entity}_Request`}, - }, - }, - }, - responses: { - '200': { - description: 'Success response', - content: { - 'application/json': { - schema: { - $ref: '#/components/schemas/execute-connector_Response', - }, - }, - }, - }, - }, - }, - }; -} - -/** Request schema for the create operation. */ -export function createOperationRequest( - entity: string, -): Record { +/** Request schema for executing an action. */ +export function actionRequest(action: string): OpenAPIV3.SchemaObject { return { type: 'object', - required: [ - 'connectorInputPayload', - 'operation', - 'connectionName', - 'serviceName', - 'host', - 'entity', - ], + required: [...ACTION_CORE, 'connectorInputPayload'], properties: { - connectorInputPayload: { - $ref: `#/components/schemas/connectorInputPayload_${entity}`, - }, - 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'}, + ...refs(...ACTION_CORE), + connectorInputPayload: ref(`connectorInputPayload_${action}`), + ...refs('dynamicAuthConfig'), }, }; } -/** Request schema for the update operation. */ -export function updateOperationRequest( - entity: string, -): Record { +/** Response schema for executing an action. */ +export function actionResponse(action: string): OpenAPIV3.SchemaObject { return { type: 'object', - required: [ - 'connectorInputPayload', - 'entityId', - 'operation', - 'connectionName', - 'serviceName', - 'host', - 'entity', - ], properties: { - connectorInputPayload: { - $ref: `#/components/schemas/connectorInputPayload_${entity}`, - }, - 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'}, + connectorOutputPayload: ref(`connectorOutputPayload_${action}`), }, }; } -/** Request schema for the get operation. */ -export function getOperationRequest(): Record { +/** Request schema for the built-in custom-query action. */ +export function executeCustomQueryRequest(): OpenAPIV3.SchemaObject { return { 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'}, - }, + required: [...ACTION_CORE, 'query', 'timeout', 'pageSize'], + properties: refs( + ...ACTION_CORE, + 'query', + 'timeout', + 'pageSize', + 'dynamicAuthConfig', + ), }; } -/** Request schema for the delete operation. */ -export function deleteOperationRequest(): Record { - return { - 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'}, - }, - }; -} +/** + * 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 = {}; -/** Request schema for the list operation. */ -export function listOperationRequest(): Record { - return { - 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'}, - }, - }; -} + if ('description' in jsonSchema) { + openApiSchema['description'] = jsonSchema['description']; + } -/** Request schema for executing an action. */ -export function actionRequest(action: string): Record { - return { - 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_${action}`, - }, - dynamicAuthConfig: {$ref: '#/components/schemas/dynamicAuthConfig'}, - }, - }; + 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; } -/** Response schema for executing an action. */ -export function actionResponse(action: string): Record { - return { - type: 'object', - properties: { - connectorOutputPayload: { - $ref: `#/components/schemas/connectorOutputPayload_${action}`, - }, - }, - }; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } -/** Request schema for the built-in custom-query action. */ -export function executeCustomQueryRequest(): Record { - return { - 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 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 index 38255a227..1da948389 100644 --- a/core/src/tools/application_integration_tool/clients/integration_client.ts +++ b/core/src/tools/application_integration_tool/clients/integration_client.ts @@ -11,20 +11,11 @@ import {ConnectionsClient} from './connections_client.js'; import { actionRequest, actionResponse, - ConnectorSpec, - createOperation, - createOperationRequest, - deleteOperation, - deleteOperationRequest, + buildEntityOperation, + convertJsonSchemaToOpenApiSchema, executeCustomQueryRequest, getActionOperation, getConnectorBaseSpec, - getOperation, - getOperationRequest, - listOperation, - listOperationRequest, - updateOperation, - updateOperationRequest, } from './connector_spec_builders.js'; /** Integration published by default to execute connector operations. */ @@ -39,11 +30,6 @@ export const EXECUTE_CUSTOM_QUERY_ACTION = 'ExecuteCustomQuery'; */ export type EntityOperations = Record; -/** Returns the regional Application Integration host for a location. */ -export function getIntegrationsEndpoint(location: string): string { - return `${location}-integrations.googleapis.com`; -} - /** Constructor options for {@link IntegrationClient}. */ export interface IntegrationClientOptions { /** The Google Cloud project ID. */ @@ -82,12 +68,10 @@ interface GenerateOpenApiSpecResponse { @experimental export class IntegrationClient { private readonly options: IntegrationClientOptions; - private readonly connection: string; private readonly tokenProvider: AccessTokenProvider; constructor(options: IntegrationClientOptions) { this.options = options; - this.connection = options.connection ?? ''; this.tokenProvider = new AccessTokenProvider(options.serviceAccountJson); } @@ -103,7 +87,7 @@ export class IntegrationClient { } const response = await executeApiCall({ - url: `https://${getIntegrationsEndpoint(location)}/v1/projects/${project}/locations/${location}:generateOpenApiSpec`, + url: `https://${location}-integrations.googleapis.com/v1/projects/${project}/locations/${location}:generateOpenApiSpec`, method: 'POST', tokenProvider: this.tokenProvider, extraHeaders, @@ -151,7 +135,7 @@ export class IntegrationClient { const connectionsClient = new ConnectionsClient({ project, location, - connection: this.connection, + connection: this.options.connection ?? '', serviceAccountJson: this.options.serviceAccountJson, }); const executePath = `/v2/projects/${project}/locations/${location}/integrations/${integrationName}:execute?triggerId=api_trigger/${integrationName}`; @@ -161,20 +145,27 @@ export class IntegrationClient { const {schema, operations: supportedOperations} = await connectionsClient.getEntitySchemaAndOperations(entity); spec.components.schemas[`connectorInputPayload_${entity}`] = - connectionsClient.connectorPayload(schema); + convertJsonSchemaToOpenApiSchema(schema); const schemaAsString = JSON.stringify(schema); for (const operation of operations.length ? operations : supportedOperations) { - addEntityOperation(spec, { + const built = buildEntityOperation({ entity, operation, schemaAsString, - path: `${executePath}#${operation.toLowerCase()}_${entity}`, 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; } } @@ -192,10 +183,10 @@ export class IntegrationClient { spec.components.schemas[`${displayName}_Request`] = actionRequest(displayName); spec.components.schemas[`connectorInputPayload_${displayName}`] = - connectionsClient.connectorPayload(actionDetails.inputSchema); + convertJsonSchemaToOpenApiSchema(actionDetails.inputSchema); } spec.components.schemas[`connectorOutputPayload_${displayName}`] = - connectionsClient.connectorPayload(actionDetails.outputSchema); + convertJsonSchemaToOpenApiSchema(actionDetails.outputSchema); spec.components.schemas[`${displayName}_Response`] = actionResponse(displayName); spec.paths[`${executePath}#${action}`] = getActionOperation( @@ -207,64 +198,7 @@ export class IntegrationClient { ); } - // The generated fragments form an OpenAPI document once assembled. - return spec as OpenAPIV3.Document; - } -} - -interface EntityOperationRequest { - entity: string; - operation: string; - schemaAsString: string; - path: string; - toolName: string; - toolInstructions: string; -} - -function addEntityOperation( - spec: ConnectorSpec, - request: EntityOperationRequest, -): void { - const {entity, path, schemaAsString, toolName, toolInstructions} = request; - const {paths, components} = spec; - - switch (request.operation.toLowerCase()) { - case 'create': - paths[path] = createOperation(entity, toolName, toolInstructions); - components.schemas[`create_${entity}_Request`] = - createOperationRequest(entity); - return; - case 'update': - paths[path] = updateOperation(entity, toolName, toolInstructions); - components.schemas[`update_${entity}_Request`] = - updateOperationRequest(entity); - return; - case 'delete': - paths[path] = deleteOperation(entity, toolName, toolInstructions); - components.schemas[`delete_${entity}_Request`] = deleteOperationRequest(); - return; - case 'list': - paths[path] = listOperation( - entity, - schemaAsString, - toolName, - toolInstructions, - ); - components.schemas[`list_${entity}_Request`] = listOperationRequest(); - return; - case 'get': - paths[path] = getOperation( - entity, - schemaAsString, - toolName, - toolInstructions, - ); - components.schemas[`get_${entity}_Request`] = getOperationRequest(); - return; - default: - throw new Error( - `Invalid operation: ${request.operation} for entity: ${entity}`, - ); + return spec; } } 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/test/tools/application_integration_tool/clients/api_request_test.ts b/core/test/tools/application_integration_tool/clients/api_request_test.ts index 96243f9b6..b619c3306 100644 --- a/core/test/tools/application_integration_tool/clients/api_request_test.ts +++ b/core/test/tools/application_integration_tool/clients/api_request_test.ts @@ -9,7 +9,6 @@ import { AccessTokenProvider, DEFAULT_REQUEST_TIMEOUT_MS, executeApiCall, - toMessage, } from '../../../../src/tools/application_integration_tool/clients/api_request.js'; const CLOUD_PLATFORM_SCOPES = [ @@ -287,13 +286,3 @@ describe('executeApiCall', () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); - -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/tools/application_integration_tool/clients/connections_client_test.ts b/core/test/tools/application_integration_tool/clients/connections_client_test.ts index 12248a2ca..d3195cbca 100644 --- a/core/test/tools/application_integration_tool/clients/connections_client_test.ts +++ b/core/test/tools/application_integration_tool/clients/connections_client_test.ts @@ -7,7 +7,6 @@ import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import { ConnectionsClient, - convertJsonSchemaToOpenApiSchema, MAX_POLL_ATTEMPTS, POLL_INTERVAL_MS, } from '../../../../src/tools/application_integration_tool/clients/connections_client.js'; @@ -248,102 +247,4 @@ describe('ConnectionsClient', () => { await assertion; }); - - it('exposes the payload converter as a client method', () => { - expect(newClient().connectorPayload({type: 'string'})).toEqual({ - type: 'string', - }); - }); -}); - -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/connector_spec_builders_test.ts b/core/test/tools/application_integration_tool/clients/connector_spec_builders_test.ts index 62fa32024..220feb7f6 100644 --- 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 @@ -8,19 +8,11 @@ import {describe, expect, it} from 'vitest'; import { actionRequest, actionResponse, - createOperation, - createOperationRequest, - deleteOperation, - deleteOperationRequest, + buildEntityOperation, + convertJsonSchemaToOpenApiSchema, executeCustomQueryRequest, getActionOperation, getConnectorBaseSpec, - getOperation, - getOperationRequest, - listOperation, - listOperationRequest, - updateOperation, - updateOperationRequest, } from '../../../../src/tools/application_integration_tool/clients/connector_spec_builders.js'; /** @@ -656,6 +648,20 @@ const CUSTOM_QUERY_REQUEST = { }, }; +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); @@ -685,69 +691,140 @@ describe('connector spec builders', () => { ).toEqual(ACTION_QUERY_OPERATION); }); - it('builds a list operation', () => { - expect(listOperation('Issues', '{"type":"object"}', 'tp', 'INSTR')).toEqual( - LIST_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('builds a get operation', () => { - expect(getOperation('Issues', '{"type":"object"}', 'tp', 'INSTR')).toEqual( - GET_OPERATION, - ); + it('reports an operation the connector spec cannot express', () => { + expect( + buildEntityOperation({ + operation: 'INVALID', + entity: 'Issues', + schemaAsString: '', + toolName: 'tp', + toolInstructions: '', + }), + ).toBeUndefined(); }); - it('builds a create operation', () => { - expect(createOperation('Issues', 'tp', 'INSTR')).toEqual(CREATE_OPERATION); + it('builds the action request schema', () => { + expect(actionRequest('TestAction')).toEqual(ACTION_REQUEST); }); - it('builds an update operation', () => { - expect(updateOperation('Issues', 'tp', 'INSTR')).toEqual(UPDATE_OPERATION); + it('builds the action response schema', () => { + expect(actionResponse('TestAction')).toEqual(ACTION_RESPONSE); }); - it('builds a delete operation', () => { - expect(deleteOperation('Issues', 'tp', 'INSTR')).toEqual(DELETE_OPERATION); + it('builds the custom query request schema', () => { + expect(executeCustomQueryRequest()).toEqual(CUSTOM_QUERY_REQUEST); }); +}); - it('builds the create request schema', () => { - expect(createOperationRequest('Issues')).toEqual(CREATE_REQUEST); +describe('convertJsonSchemaToOpenApiSchema', () => { + it('copies a plain type and description', () => { + expect( + convertJsonSchemaToOpenApiSchema({ + type: 'string', + description: 'a string', + }), + ).toEqual({type: 'string', description: 'a string'}); }); - it('builds the update request schema', () => { - expect(updateOperationRequest('Issues')).toEqual(UPDATE_REQUEST); + 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('builds the get request schema', () => { - expect(getOperationRequest()).toEqual(GET_REQUEST); + it('marks a null-only type as nullable without a type', () => { + expect(convertJsonSchemaToOpenApiSchema({type: ['null']})).toEqual({ + nullable: true, + }); }); - it('builds the delete request schema', () => { - expect(deleteOperationRequest()).toEqual(DELETE_REQUEST); + it('takes the first entry of a union without null', () => { + expect( + convertJsonSchemaToOpenApiSchema({type: ['integer', 'string']}), + ).toEqual({type: 'integer'}); }); - it('builds the list request schema', () => { - expect(listOperationRequest()).toEqual(LIST_REQUEST); + it('drops keys the connector spec does not use', () => { + expect( + convertJsonSchemaToOpenApiSchema({title: 'ignored', minLength: 3}), + ).toEqual({}); }); - it('builds the action request schema', () => { - expect(actionRequest('TestAction')).toEqual(ACTION_REQUEST); + 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('builds the action response schema', () => { - expect(actionResponse('TestAction')).toEqual(ACTION_RESPONSE); + it('ignores properties that are not objects', () => { + expect( + convertJsonSchemaToOpenApiSchema({ + type: 'object', + properties: {broken: 'not-a-schema'}, + }), + ).toEqual({type: 'object', properties: {broken: {}}}); }); - it('builds the custom query request schema', () => { - expect(executeCustomQueryRequest()).toEqual(CUSTOM_QUERY_REQUEST); + 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('defaults the tool name and instructions to empty strings', () => { - const operation = listOperation('Issues'); - expect(operation).toEqual({ - post: expect.objectContaining({ - operationId: '_list_Issues', - description: expect.stringContaining('`. '), + 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 index 58c98f85e..1dc39f569 100644 --- a/core/test/tools/application_integration_tool/clients/integration_client_test.ts +++ b/core/test/tools/application_integration_tool/clients/integration_client_test.ts @@ -34,7 +34,6 @@ const {authClient, googleAuthCtor, connectionsClient, connectionsClientCtor} = const connectionsClient = { getEntitySchemaAndOperations: vi.fn(), getActionSchema: vi.fn(), - connectorPayload: vi.fn(), }; return { authClient: { @@ -88,9 +87,6 @@ describe('IntegrationClient', () => { description: 'Test action', displayName: 'TestAction', }); - connectionsClient.connectorPayload.mockImplementation( - (schema: unknown) => ({converted: schema}), - ); }); afterEach(() => { @@ -100,7 +96,6 @@ describe('IntegrationClient', () => { connectionsClientCtor.mockClear(); connectionsClient.getEntitySchemaAndOperations.mockReset(); connectionsClient.getActionSchema.mockReset(); - connectionsClient.connectorPayload.mockReset(); }); describe('getOpenApiSpecForIntegration', () => { @@ -257,9 +252,10 @@ describe('IntegrationClient', () => { 'get_entity1_Request', ]), ); + // The entity's JSON schema is converted to an OpenAPI payload schema. expect( spec.components?.schemas?.['connectorInputPayload_entity1'], - ).toEqual({converted: {type: 'object'}}); + ).toEqual({type: 'object'}); expect(connectionsClientCtor).toHaveBeenCalledWith({ project: PROJECT, location: LOCATION, 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]'); + }); +});