Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export * from './artifacts/base_artifact_service.js';
export * from './features/feature_registry.js';
export * from './memory/base_memory_service.js';
export * from './sessions/base_session_service.js';
export type {EntityOperations} from './tools/application_integration_tool/clients/integration_client.js';
export * from './tools/base_tool.js';
export {OpenApiSpecParser} from './tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.js';
export type {
Expand Down
144 changes: 144 additions & 0 deletions core/src/tools/application_integration_tool/clients/api_request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {AnyAuthClient, GoogleAuth, JWT} from 'google-auth-library';
import {toMessage} from '../../../utils/error_utils.js';
import {parseServiceAccountCredential} from '../../../utils/service_account_utils.js';

/** Upper bound on how long a single Google API request may take. */
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;

const CLOUD_PLATFORM_SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
];

const MISSING_CREDENTIALS_MESSAGE =
'Please provide a service account that has the required permissions to' +
' access the connection.';

/**
* Supplies OAuth2 access tokens for Google API calls, either from an explicit
* service-account key file or from Application Default Credentials.
*
* The underlying `google-auth-library` client caches and refreshes tokens on
* its own, so a token is requested per call rather than cached here.
*/
export class AccessTokenProvider {
private readonly auth: GoogleAuth<AnyAuthClient>;
private readonly hasExplicitServiceAccount: boolean;

constructor(serviceAccountJson?: string) {
this.hasExplicitServiceAccount = Boolean(serviceAccountJson);
this.auth = serviceAccountJson
? new GoogleAuth<AnyAuthClient>({
authClient: createServiceAccountClient(serviceAccountJson),
scopes: CLOUD_PLATFORM_SCOPES,
})
: new GoogleAuth<AnyAuthClient>({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<string> {
let token: string | null | undefined;
try {
const client = await this.auth.getClient();
token = (await client.getAccessToken()).token;
} catch (err: unknown) {
throw this.credentialsError(err);
}
if (!token) {
throw new Error(MISSING_CREDENTIALS_MESSAGE);
}
return token;
}

/**
* The billing/quota project advertised by the resolved credentials, if any.
*
* @throws {Error} If no usable credentials are available.
*/
async getQuotaProjectId(): Promise<string | undefined> {
try {
return (await this.auth.getClient()).quotaProjectId;
} catch (err: unknown) {
throw this.credentialsError(err);
}
}

private credentialsError(err: unknown): Error {
return this.hasExplicitServiceAccount
? new Error(`Credentials error: ${toMessage(err)}`)
: new Error(MISSING_CREDENTIALS_MESSAGE);
}
}

function createServiceAccountClient(serviceAccountJson: string): JWT {
const credential = parseServiceAccountCredential(serviceAccountJson);
return new JWT({
email: credential.clientEmail,
key: credential.privateKey,
scopes: CLOUD_PLATFORM_SCOPES,
});
}

/** Options for {@link executeApiCall}. */
export interface ApiCallOptions {
url: string;
method: 'GET' | 'POST';
tokenProvider: AccessTokenProvider;
/** JSON request body; omitted for GET requests. */
body?: unknown;
extraHeaders?: Record<string, string>;
/** 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<T>(options: ApiCallOptions): Promise<T> {
const token = await options.tokenProvider.getAccessToken();
const headers: Record<string, string> = {
'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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {experimental} from '../../../utils/experimental.js';
import {AccessTokenProvider, executeApiCall} from './api_request.js';

/** Host serving the Integration Connectors admin API. */
export const CONNECTORS_ENDPOINT = 'connectors.googleapis.com';

/** How many times a long-running operation is polled before giving up. */
export const MAX_POLL_ATTEMPTS = 30;

/** Delay between two polls of a long-running operation. */
export const POLL_INTERVAL_MS = 1000;

/** Service details of an Integration Connector connection. */
export interface ConnectionDetails {
name: string;
serviceName: string;
host: string;
authOverrideEnabled: boolean;
}

/** The JSON schema of an entity plus the operations it supports. */
export interface EntitySchemaAndOperations {
schema: Record<string, unknown>;
operations: string[];
}

/** The input/output schemas and metadata of a connector action. */
export interface ActionSchema {
inputSchema: Record<string, unknown>;
outputSchema: Record<string, unknown>;
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<string, unknown>;
operations?: string[];
inputJsonSchema?: Record<string, unknown>;
outputJsonSchema?: Record<string, unknown>;
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<ConnectionDetails> {
const url = `${this.connectorUrl}/v1/projects/${this.project}/locations/${this.location}/connections/${this.connection}?view=BASIC`;
const connection = await this.get<ConnectionResource>(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<EntitySchemaAndOperations> {
const url = `${this.connectorUrl}/v1/projects/${this.project}/locations/${this.location}/connections/${this.connection}/connectionSchemaMetadata:getEntityType?entityId=${entity}`;
const operationId = (await this.get<OperationResource>(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<ActionSchema> {
const url = `${this.connectorUrl}/v1/projects/${this.project}/locations/${this.location}/connections/${this.connection}/connectionSchemaMetadata:getAction?actionId=${action}`;
const operationId = (await this.get<OperationResource>(url)).name;
if (!operationId) {
throw new Error(`Failed to get action schema for action: ${action}`);
}

const operation = await this.pollOperation(operationId);
return {
inputSchema: operation.response?.inputJsonSchema ?? {},
outputSchema: operation.response?.outputJsonSchema ?? {},
description: operation.response?.description ?? '',
displayName: operation.response?.displayName ?? '',
};
}

private get<T>(url: string): Promise<T> {
return executeApiCall<T>({
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<OperationResource> {
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<OperationResource>(url);
if (operation.done) {
return operation;
}
}
throw new Error(
`Timed out waiting for operation ${operationId} to complete`,
);
}
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Loading
Loading