From c619247aea853fa2dcef1e839101f75d2e13431e Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 20 Apr 2026 12:20:23 -0700 Subject: [PATCH 1/8] feat: Implement OpenAPI Toolset --- .../tools/openapi_tool/auth/auth_helpers.ts | 73 ++ .../auto_auth_credential_exchanger.ts | 63 ++ .../service_account_exchanger.ts | 126 +++ .../openapi_spec_parser.ts | 185 ++++ .../openapi_spec_parser/operation_parser.ts | 213 +++++ .../openapi_spec_parser/tool_auth_handler.ts | 70 ++ .../src/tools/openapi_tool/openapi_toolset.ts | 110 +++ core/src/tools/openapi_tool/rest_api_tool.ts | 214 +++++ .../tools/openapi_tool/auth_helpers_test.ts | 95 ++ .../credential_exchangers_test.ts | 185 ++++ .../tools/openapi_tool/fixtures/petstore.yaml | 841 ++++++++++++++++++ .../tools/openapi_tool/fixtures/truanon.yaml | 86 ++ .../openapi_toolset_integration_test.ts | 127 +++ .../openapi_tool/openapi_toolset_test.ts | 301 +++++++ .../openapi_tool/operation_parser_test.ts | 95 ++ .../tools/openapi_tool/rest_api_tool_test.ts | 275 ++++++ .../openapi_tool/tool_auth_handler_test.ts | 74 ++ 17 files changed, 3133 insertions(+) create mode 100644 core/src/tools/openapi_tool/auth/auth_helpers.ts create mode 100644 core/src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.ts create mode 100644 core/src/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.ts create mode 100644 core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts create mode 100644 core/src/tools/openapi_tool/openapi_spec_parser/operation_parser.ts create mode 100644 core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts create mode 100644 core/src/tools/openapi_tool/openapi_toolset.ts create mode 100644 core/src/tools/openapi_tool/rest_api_tool.ts create mode 100644 core/test/tools/openapi_tool/auth_helpers_test.ts create mode 100644 core/test/tools/openapi_tool/credential_exchangers_test.ts create mode 100644 core/test/tools/openapi_tool/fixtures/petstore.yaml create mode 100644 core/test/tools/openapi_tool/fixtures/truanon.yaml create mode 100644 core/test/tools/openapi_tool/openapi_toolset_integration_test.ts create mode 100644 core/test/tools/openapi_tool/openapi_toolset_test.ts create mode 100644 core/test/tools/openapi_tool/operation_parser_test.ts create mode 100644 core/test/tools/openapi_tool/rest_api_tool_test.ts create mode 100644 core/test/tools/openapi_tool/tool_auth_handler_test.ts diff --git a/core/src/tools/openapi_tool/auth/auth_helpers.ts b/core/src/tools/openapi_tool/auth/auth_helpers.ts new file mode 100644 index 00000000..714a7598 --- /dev/null +++ b/core/src/tools/openapi_tool/auth/auth_helpers.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {AuthCredential} from '../../../auth/auth_credential.js'; + +/** + * Applies the given credential to the request headers and URL. + * + * @param url The target URL. + * @param headers The request headers. + * @param credential The auth credential. + * @param authScheme The auth scheme from OpenAPI spec. + * @returns The updated URL (if modified by query params). + */ +export function applyCredential( + url: string, + headers: Record, + credential: AuthCredential, + authScheme?: OpenAPIV3.SecuritySchemeObject, +): string { + if (!credential) return url; + + if (credential.api_key) { + const inLocation = authScheme?.in; + const name = authScheme?.name || 'key'; + + if (inLocation === 'header') { + headers[name] = credential.api_key; + } else if (inLocation === 'query') { + const separator = url.includes('?') ? '&' : '?'; + url += `${separator}${name}=${encodeURIComponent(credential.api_key)}`; + } else { + // Default to header Authorization if not specified or unknown location + headers['Authorization'] = credential.api_key; + } + } else if ( + credential.http && + credential.http.credentials && + credential.http.credentials.token + ) { + headers['Authorization'] = `Bearer ${credential.http.credentials.token}`; + } + + return url; +} + +/** + * Helper to create a simple API Key auth scheme. + */ +export function createApiKeyScheme( + name: string, + inLocation: 'header' | 'query' | 'cookie', +): OpenAPIV3.SecuritySchemeObject { + return { + type: 'apiKey', + name, + in: inLocation, + }; +} + +/** + * Helper to create a simple Bearer Token auth scheme. + */ +export function createBearerScheme(): OpenAPIV3.SecuritySchemeObject { + return { + type: 'http', + scheme: 'bearer', + }; +} diff --git a/core/src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.ts b/core/src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.ts new file mode 100644 index 00000000..e860e1b1 --- /dev/null +++ b/core/src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthCredential, + AuthCredentialTypes, +} from '../../../../auth/auth_credential.js'; +import {AuthScheme} from '../../../../auth/auth_schemes.js'; +import { + BaseCredentialExchanger, + ExchangeResult, +} from '../../../../auth/exchanger/base_credential_exchanger.js'; +import {OAuth2CredentialExchanger} from '../../../../auth/oauth2/oauth2_credential_exchanger.js'; +import {experimental} from '../../../../utils/experimental.js'; +import {ServiceAccountCredentialExchanger} from './service_account_exchanger.js'; + +/** + * Automatically selects the appropriate credential exchanger based on the auth scheme. + * Ported from Python implementation. + */ +@experimental +export class AutoAuthCredentialExchanger implements BaseCredentialExchanger { + private exchangers: Map = + new Map(); + + constructor() { + this.exchangers.set( + AuthCredentialTypes.OAUTH2, + new OAuth2CredentialExchanger(), + ); + this.exchangers.set( + AuthCredentialTypes.OPEN_ID_CONNECT, + new OAuth2CredentialExchanger(), + ); + this.exchangers.set( + AuthCredentialTypes.SERVICE_ACCOUNT, + new ServiceAccountCredentialExchanger(), + ); + } + + @experimental + async exchange(params: { + authScheme?: AuthScheme; + authCredential: AuthCredential; + }): Promise { + const {authCredential, authScheme} = params; + + const exchanger = this.exchangers.get(authCredential.authType); + + if (!exchanger) { + // If no exchanger found, return the original credential as not exchanged + return { + credential: authCredential, + wasExchanged: false, + }; + } + + return exchanger.exchange({authScheme, authCredential}); + } +} diff --git a/core/src/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.ts b/core/src/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.ts new file mode 100644 index 00000000..7776d726 --- /dev/null +++ b/core/src/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {GoogleAuth, JWT} from 'google-auth-library'; +import { + AuthCredential, + AuthCredentialTypes, + ServiceAccount, +} from '../../../../auth/auth_credential.js'; +import {AuthScheme} from '../../../../auth/auth_schemes.js'; +import { + BaseCredentialExchanger, + CredentialExchangeError, + ExchangeResult, +} from '../../../../auth/exchanger/base_credential_exchanger.js'; +import {experimental} from '../../../../utils/experimental.js'; + +/** + * Fetches credentials for Google Service Account. + * Ported from Python implementation. + */ +@experimental +export class ServiceAccountCredentialExchanger implements BaseCredentialExchanger { + @experimental + async exchange(params: { + authScheme?: AuthScheme; + authCredential: AuthCredential; + }): Promise { + const {authCredential} = params; + + if ( + authCredential.authType !== AuthCredentialTypes.SERVICE_ACCOUNT || + !authCredential.serviceAccount + ) { + throw new CredentialExchangeError( + 'Invalid credential type for ServiceAccountCredentialExchanger', + ); + } + + const saConfig = authCredential.serviceAccount; + + if (saConfig.useDefaultCredential) { + return this.exchangeForDefaultCredential(saConfig); + } + + return this.exchangeForExplicitCredential(saConfig); + } + + private async exchangeForDefaultCredential( + saConfig: ServiceAccount, + ): Promise { + try { + const auth = new GoogleAuth({ + scopes: saConfig.scopes || [ + 'https://www.googleapis.com/auth/cloud-platform', + ], + }); + const client = await auth.getClient(); + const tokenResponse = await client.getAccessToken(); + const token = tokenResponse.token; + + if (!token) { + throw new Error('Failed to get access token from default credentials'); + } + + return { + credential: { + authType: AuthCredentialTypes.HTTP, + http: { + scheme: 'bearer', + credentials: {token}, + }, + }, + wasExchanged: true, + }; + } catch (error) { + throw new CredentialExchangeError( + `Failed to exchange default service account token: ${(error as Error).message}`, + ); + } + } + + private async exchangeForExplicitCredential( + saConfig: ServiceAccount, + ): Promise { + const creds = saConfig.serviceAccountCredential; + if (!creds) { + throw new CredentialExchangeError( + 'Service account credentials are missing.', + ); + } + + try { + const client = new JWT({ + email: creds.clientEmail, + key: creds.privateKey, + scopes: saConfig.scopes, + }); + + const tokens = await client.authorize(); + const token = tokens.access_token; + + if (!token) { + throw new Error('Failed to get access token from explicit credentials'); + } + + return { + credential: { + authType: AuthCredentialTypes.HTTP, + http: { + scheme: 'bearer', + credentials: {token}, + }, + }, + wasExchanged: true, + }; + } catch (error) { + throw new CredentialExchangeError( + `Failed to exchange explicit service account token: ${(error as Error).message}`, + ); + } + } +} diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts new file mode 100644 index 00000000..ae109ea1 --- /dev/null +++ b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {experimental} from '../../../utils/experimental.js'; +import {ApiParameter, OperationParser} from './operation_parser.js'; + +export interface OperationEndpoint { + baseUrl: string; + path: string; + method: string; +} + +export interface ParsedOperation { + name: string; + description: string; + endpoint: OperationEndpoint; + operation: OpenAPIV3.OperationObject; + parameters: ApiParameter[]; + returnValue?: ApiParameter; + authScheme?: OpenAPIV3.SecuritySchemeObject; +} + +@experimental +export class OpenApiSpecParser { + private preservePropertyNames: boolean; + + constructor(options: {preservePropertyNames?: boolean} = {}) { + this.preservePropertyNames = options.preservePropertyNames ?? false; + } + + @experimental + public parse(openapiSpec: OpenAPIV3.Document): ParsedOperation[] { + const resolvedSpec = this.resolveReferences(openapiSpec); + // Skipping sanitizeSchemaTypes for now unless we find it's needed for Gemini + return this.collectOperations(resolvedSpec); + } + + private resolveReferences(spec: OpenAPIV3.Document): OpenAPIV3.Document { + const resolvedCache = new Map(); + const specCopy = JSON.parse(JSON.stringify(spec)); // Deep copy + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolveRef = (refString: string, currentDoc: any) => { + const parts = refString.split('/'); + if (parts[0] !== '#') { + throw new Error(`External references not supported: ${refString}`); + } + + let current = currentDoc; + for (const part of parts.slice(1)) { + if (part in current) { + current = current[part]; + } else { + return undefined; + } + } + return current; + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const recursiveResolve = ( + obj: any, + currentDoc: any, + seenRefs = new Set(), + ): any => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => recursiveResolve(item, currentDoc, seenRefs)); + } + + if ('$ref' in obj && typeof obj['$ref'] === 'string') { + const refString = obj['$ref']; + + if (seenRefs.has(refString) && !resolvedCache.has(refString)) { + // Circular reference detected. Break cycle. + const copy = {...obj}; + delete copy['$ref']; + return copy; + } + + seenRefs.add(refString); + + if (resolvedCache.has(refString)) { + return resolvedCache.get(refString); + } + + let resolvedValue = resolveRef(refString, currentDoc); + if (resolvedValue !== undefined) { + resolvedValue = recursiveResolve(resolvedValue, currentDoc, seenRefs); + resolvedCache.set(refString, resolvedValue); + return resolvedValue; + } else { + return obj; + } + } + + const newDict: Record = {}; + for (const [key, value] of Object.entries(obj)) { + newDict[key] = recursiveResolve(value, currentDoc, seenRefs); + } + return newDict; + }; + + return recursiveResolve(specCopy, specCopy); + } + + private collectOperations(spec: OpenAPIV3.Document): ParsedOperation[] { + const operations: ParsedOperation[] = []; + const baseUrl = spec.servers?.[0]?.url || ''; + + const globalSecurity = spec.security || []; + let globalSchemeName: string | undefined; + if (globalSecurity.length > 0) { + globalSchemeName = Object.keys(globalSecurity[0])[0]; + } + + const authSchemes = + (spec.components?.securitySchemes as Record< + string, + OpenAPIV3.SecuritySchemeObject + >) || {}; + + for (const [path, pathItem] of Object.entries(spec.paths || {})) { + if (!pathItem) continue; + + const methods = [ + 'get', + 'post', + 'put', + 'delete', + 'patch', + 'head', + 'options', + 'trace', + ] as const; + + for (const method of methods) { + const operation = pathItem[method]; + if (!operation) continue; + + // Merge path level parameters + const pathParams = pathItem.parameters || []; + const opParams = operation.parameters || []; + operation.parameters = [...opParams, ...pathParams]; + + if (!operation.operationId) { + // Generate operation ID if missing + operation.operationId = `${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`; + } + + const parser = new OperationParser(operation, { + preservePropertyNames: this.preservePropertyNames, + }); + + let authSchemeName: string | undefined; + if (operation.security && operation.security.length > 0) { + authSchemeName = Object.keys(operation.security[0])[0]; + } + authSchemeName = authSchemeName || globalSchemeName; + + const authScheme = authSchemeName + ? authSchemes[authSchemeName] + : undefined; + + operations.push({ + name: parser.getFunctionName(), + description: parser.getDescription(), + endpoint: {baseUrl, path, method}, + operation: operation, + parameters: parser.getParameters(), + authScheme: authScheme, + }); + } + } + + return operations; + } +} diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/operation_parser.ts b/core/src/tools/openapi_tool/openapi_spec_parser/operation_parser.ts new file mode 100644 index 00000000..1827d6e0 --- /dev/null +++ b/core/src/tools/openapi_tool/openapi_spec_parser/operation_parser.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {experimental} from '../../../utils/experimental.js'; + +export interface ApiParameter { + originalName: string; + paramLocation: string; + paramSchema: OpenAPIV3.SchemaObject; + description?: string; + name: string; // The name used in the generated tool schema (may be snake_cased) + required: boolean; +} + +@experimental +export class OperationParser { + private params: ApiParameter[] = []; + private returnValue?: ApiParameter; + private preservePropertyNames: boolean; + + constructor( + private readonly operation: OpenAPIV3.OperationObject, + options: {preservePropertyNames?: boolean} = {}, + ) { + this.preservePropertyNames = options.preservePropertyNames ?? false; + this.processOperationParameters(); + this.processRequestBody(); + this.processReturnValue(); + this.dedupeParamNames(); + } + + private getParamName(originalName: string): string { + if (this.preservePropertyNames) { + return originalName; + } + // Simple snake_case conversion + return originalName + .replace(/[A-Z]/g, (g) => '_' + g.toLowerCase()) + .replace(/^_/, ''); + } + + private processOperationParameters() { + const parameters = this.operation.parameters || []; + for (const param of parameters) { + // Assume resolved references for now + if ('name' in param) { + const originalName = param.name; + const description = param.description || ''; + const location = param.in || ''; + const schema = (param.schema as OpenAPIV3.SchemaObject) || {}; + + this.params.push({ + originalName, + paramLocation: location, + paramSchema: schema, + description, + required: param.required || false, + name: this.getParamName(originalName), + }); + } + } + } + + private processRequestBody() { + const requestBody = this.operation.requestBody; + if (!requestBody || '$ref' in requestBody) { + return; + } + + const content = requestBody.content || {}; + // Process first mime type only, similar to python + const firstMimeType = Object.keys(content)[0]; + if (!firstMimeType) { + return; + } + + const mediaTypeObject = content[firstMimeType]; + const schema = mediaTypeObject.schema; + const description = requestBody.description || ''; + + if (schema && !('$ref' in schema)) { + if (schema.type === 'object') { + const properties = schema.properties || {}; + if (Object.keys(properties).length > 0) { + for (const [propName, propDetails] of Object.entries(properties)) { + if (!('$ref' in propDetails)) { + this.params.push({ + originalName: propName, + paramLocation: 'body', + paramSchema: propDetails, + description: propDetails.description, + required: (schema.required || []).includes(propName), + name: this.getParamName(propName), + }); + } + } + } else { + this.params.push({ + originalName: '', + paramLocation: 'body', + paramSchema: schema, + description, + required: true, + name: 'body', + }); + } + } else if (schema.type === 'array') { + this.params.push({ + originalName: 'array', + paramLocation: 'body', + paramSchema: schema, + description, + required: true, + name: 'body', + }); + } else { + this.params.push({ + originalName: 'body', + paramLocation: 'body', + paramSchema: schema, + description, + required: true, + name: 'body', + }); + } + } + } + + private processReturnValue() { + const responses = this.operation.responses || {}; + // Find first 2xx response + const validCodes = Object.keys(responses).filter((k) => k.startsWith('2')); + const min20x = validCodes.sort()[0]; + + let returnSchema: OpenAPIV3.SchemaObject = {}; + + if (min20x) { + const response = responses[min20x]; + if (!('$ref' in response) && response.content) { + const firstMimeType = Object.keys(response.content)[0]; + if (firstMimeType) { + const schema = response.content[firstMimeType].schema; + if (schema && !('$ref' in schema)) { + returnSchema = schema; + } + } + } + } + + this.returnValue = { + originalName: '', + paramLocation: '', + paramSchema: returnSchema, + required: true, + name: 'return', + }; + } + + private dedupeParamNames() { + const nameCounts = new Map(); + for (const param of this.params) { + const name = param.name; + const count = nameCounts.get(name) || 0; + if (count > 0) { + param.name = `${name}_${count}`; + } + nameCounts.set(name, count + 1); + } + } + + @experimental + public getParameters(): ApiParameter[] { + return this.params; + } + + @experimental + public getJsonSchema(): Record { + const properties: Record = {}; + const required: string[] = []; + + for (const param of this.params) { + properties[param.name] = param.paramSchema; + if (param.required) { + required.push(param.name); + } + } + + return { + type: 'object', + properties, + required: required.length > 0 ? required : undefined, + title: `${this.operation.operationId || 'unnamed'}_Arguments`, + }; + } + + @experimental + public getFunctionName(): string { + const operationId = this.operation.operationId; + if (!operationId) { + throw new Error('Operation ID is missing'); + } + return this.getParamName(operationId).substring(0, 60); + } + + @experimental + public getDescription(): string { + return this.operation.description || this.operation.summary || ''; + } +} diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts new file mode 100644 index 00000000..377b033c --- /dev/null +++ b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {Context} from '../../../agents/context.js'; +import {AuthCredential} from '../../../auth/auth_credential.js'; +import {AuthConfig} from '../../../auth/auth_tool.js'; +import {experimental} from '../../../utils/experimental.js'; +import {AutoAuthCredentialExchanger} from '../auth/credential_exchangers/auto_auth_credential_exchanger.js'; + +export interface AuthPreparationResult { + state: 'pending' | 'done'; + authCredential?: AuthCredential; +} + +@experimental +export class ToolAuthHandler { + constructor( + private readonly context: Context, + private readonly authScheme?: OpenAPIV3.SecuritySchemeObject, + private readonly authCredential?: AuthCredential, + private readonly credentialKey?: string, + ) {} + + @experimental + public static fromToolContext( + context: Context, + authScheme?: OpenAPIV3.SecuritySchemeObject, + authCredential?: AuthCredential, + options: {credentialKey?: string} = {}, + ): ToolAuthHandler { + return new ToolAuthHandler( + context, + authScheme, + authCredential, + options.credentialKey, + ); + } + + @experimental + public async prepareAuthCredentials(): Promise { + if (!this.authScheme) { + return {state: 'done'}; + } + + const authConfig: AuthConfig = { + authScheme: this.authScheme, + rawAuthCredential: this.authCredential, + credentialKey: this.credentialKey || 'default_openapi_key', + }; + + const credential = this.context.getAuthResponse(authConfig); + if (credential) { + const exchanger = new AutoAuthCredentialExchanger(); + const result = await exchanger.exchange({ + authScheme: this.authScheme, + authCredential: credential, + }); + return {state: 'done', authCredential: result.credential}; + } + + // If credential is not available, request it + this.context.requestCredential(authConfig); + + return {state: 'pending'}; + } +} diff --git a/core/src/tools/openapi_tool/openapi_toolset.ts b/core/src/tools/openapi_tool/openapi_toolset.ts new file mode 100644 index 00000000..216d2d18 --- /dev/null +++ b/core/src/tools/openapi_tool/openapi_toolset.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import yaml from 'js-yaml'; +import {OpenAPIV3} from 'openapi-types'; +import {ReadonlyContext} from '../../agents/readonly_context.js'; +import {AuthCredential} from '../../auth/auth_credential.js'; +import {experimental} from '../../utils/experimental.js'; +import {BaseTool} from '../base_tool.js'; +import {BaseToolset, ToolPredicate} from '../base_toolset.js'; +import {OpenApiSpecParser} from './openapi_spec_parser/openapi_spec_parser.js'; +import {RestApiTool} from './rest_api_tool.js'; + +@experimental +export class OpenAPIToolset extends BaseToolset { + private tools: RestApiTool[] = []; + + constructor( + options: { + specDict?: OpenAPIV3.Document; + specStr?: string; + specType?: 'json' | 'yaml'; + toolFilter?: ToolPredicate | string[]; + prefix?: string; + preservePropertyNames?: boolean; + authScheme?: OpenAPIV3.SecuritySchemeObject; + authCredential?: AuthCredential; + credentialKey?: string; + headerProvider?: (context: ReadonlyContext) => Record; + } = {}, + ) { + super(options.toolFilter || [], options.prefix); + + let spec = options.specDict; + if (!spec && options.specStr) { + if ( + options.specType === 'yaml' || + (!options.specType && options.specStr.trim().startsWith('---')) + ) { + spec = yaml.load(options.specStr) as OpenAPIV3.Document; + } else { + spec = JSON.parse(options.specStr) as OpenAPIV3.Document; + } + } + + if (!spec) { + throw new Error('Either specDict or specStr must be provided.'); + } + + const parser = new OpenApiSpecParser({ + preservePropertyNames: options.preservePropertyNames, + }); + const parsedOperations = parser.parse(spec); + + for (const op of parsedOperations) { + let toolName = op.name; + if (this.prefix) { + toolName = `${this.prefix}_${toolName}`; + } + + const tool = RestApiTool.fromParsedOperation( + { + name: toolName, + description: op.description, + endpoint: op.endpoint, + operation: op.operation, + authScheme: op.authScheme, + }, + { + preservePropertyNames: options.preservePropertyNames, + headerProvider: options.headerProvider, + credentialKey: options.credentialKey, + }, + ); + + this.tools.push(tool); + } + + // Apply global auth overrides if provided + if (options.authScheme || options.authCredential) { + for (const tool of this.tools) { + if (options.authScheme) tool.configureAuthScheme(options.authScheme); + if (options.authCredential) + tool.configureAuthCredential(options.authCredential); + } + } + } + + @experimental + override async getTools(context?: ReadonlyContext): Promise { + return this.tools.filter((tool) => { + if (Array.isArray(this.toolFilter) && this.toolFilter.length > 0) { + return (this.toolFilter as string[]).includes(tool.name); + } + if (context) { + return this.isToolSelected(tool, context); + } + return true; + }); + } + + @experimental + override async close(): Promise { + // No persistent connections to close in this implementation + return Promise.resolve(); + } +} diff --git a/core/src/tools/openapi_tool/rest_api_tool.ts b/core/src/tools/openapi_tool/rest_api_tool.ts new file mode 100644 index 00000000..24ef1a0d --- /dev/null +++ b/core/src/tools/openapi_tool/rest_api_tool.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FunctionDeclaration} from '@google/genai'; +import {OpenAPIV3} from 'openapi-types'; +import {Context} from '../../agents/context.js'; +import {ReadonlyContext} from '../../agents/readonly_context.js'; +import {AuthCredential} from '../../auth/auth_credential.js'; +import {experimental} from '../../utils/experimental.js'; +import {BaseTool, RunAsyncToolRequest} from '../base_tool.js'; +import {applyCredential} from './auth/auth_helpers.js'; +import {OperationParser} from './openapi_spec_parser/operation_parser.js'; +import {ToolAuthHandler} from './openapi_spec_parser/tool_auth_handler.js'; + +export interface OperationEndpoint { + baseUrl: string; + path: string; + method: string; +} + +@experimental +export class RestApiTool extends BaseTool { + private operationParser: OperationParser; + + private headerProvider?: (context: ReadonlyContext) => Record; + private credentialKey?: string; + + constructor( + name: string, + description: string, + private readonly endpoint: OperationEndpoint, + private readonly operation: OpenAPIV3.OperationObject, + private authScheme?: OpenAPIV3.SecuritySchemeObject, + private authCredential?: AuthCredential, + options: { + preservePropertyNames?: boolean; + headerProvider?: (context: ReadonlyContext) => Record; + credentialKey?: string; + } = {}, + ) { + super({name, description}); + this.authScheme = authScheme; + this.authCredential = authCredential; + this.headerProvider = options.headerProvider; + this.credentialKey = options.credentialKey; + this.operationParser = new OperationParser(operation, options); + } + + @experimental + public configureAuthScheme(authScheme: OpenAPIV3.SecuritySchemeObject) { + this.authScheme = authScheme; + } + + @experimental + public configureAuthCredential(authCredential: AuthCredential) { + this.authCredential = authCredential; + } + + @experimental + public configureCredentialKey(credentialKey: string) { + this.credentialKey = credentialKey; + } + + @experimental + public static fromParsedOperation( + parsed: { + name: string; + description: string; + endpoint: OperationEndpoint; + operation: OpenAPIV3.OperationObject; + authScheme?: OpenAPIV3.SecuritySchemeObject; + }, + options: { + preservePropertyNames?: boolean; + headerProvider?: (context: ReadonlyContext) => Record; + credentialKey?: string; + } = {}, + ): RestApiTool { + return new RestApiTool( + parsed.name, + parsed.description, + parsed.endpoint, + parsed.operation, + parsed.authScheme, + undefined, // Credential will be filled later or via context + options, + ); + } + + @experimental + override _getDeclaration(): FunctionDeclaration { + const schema = this.operationParser.getJsonSchema(); + return { + name: this.name, + description: this.description, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parameters: schema as any, // Cast to any if types don't match exactly + }; + } + + @experimental + override async runAsync(request: RunAsyncToolRequest): Promise { + const context = request.toolContext as Context; + const args = request.args; + + const authHandler = ToolAuthHandler.fromToolContext( + context, + this.authScheme, + undefined, // We rely on context to provide credential + ); + + const authResult = await authHandler.prepareAuthCredentials(); + if (authResult.state === 'pending') { + return { + pending: true, + message: 'Needs your authorization to access your data.', + }; + } + + const credential = authResult.authCredential; + + // Prepare request + const method = this.endpoint.method.toUpperCase(); + let url = `${this.endpoint.baseUrl}${this.endpoint.path}`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + const queryParams = new URLSearchParams(); + let body: unknown = undefined; + + const parameters = this.operationParser.getParameters(); + const paramsMap = new Map(parameters.map((p) => [p.name, p])); + + const pathParams: Record = {}; + const bodyData: Record = {}; + + for (const [argName, argValue] of Object.entries(args)) { + const param = paramsMap.get(argName); + if (!param) continue; + + const originalName = param.originalName; + const location = param.paramLocation; + + if (location === 'path') { + pathParams[originalName] = String(argValue); + } else if (location === 'query') { + queryParams.append(originalName, String(argValue)); + } else if (location === 'header') { + headers[originalName] = String(argValue); + } else if (location === 'body') { + if ( + originalName === 'body' || + originalName === 'array' || + originalName === '' + ) { + body = argValue; + } else { + bodyData[originalName] = argValue; + } + } + } + + // Replace path parameters + for (const [key, value] of Object.entries(pathParams)) { + url = url.replace(`{${key}}`, value); + } + + // Append query parameters + const queryString = queryParams.toString(); + if (queryString) { + url += `?${queryString}`; + } + + // Handle body + if (body === undefined && Object.keys(bodyData).length > 0) { + body = JSON.stringify(bodyData); + } else if (body !== undefined && typeof body !== 'string') { + body = JSON.stringify(body); + } + + // Handle Auth + url = applyCredential(url, headers, credential, this.authScheme); + + // Apply dynamic headers from provider + if (this.headerProvider) { + const providerHeaders = this.headerProvider(context); + Object.assign(headers, providerHeaders); + } + + try { + const response = await globalThis.fetch(url, { + method, + headers, + body, + }); + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return await response.json(); + } else { + return await response.text(); + } + } catch (error) { + return { + error: `Failed to execute API call: ${(error as Error).message}`, + }; + } + } +} diff --git a/core/test/tools/openapi_tool/auth_helpers_test.ts b/core/test/tools/openapi_tool/auth_helpers_test.ts new file mode 100644 index 00000000..b19f7469 --- /dev/null +++ b/core/test/tools/openapi_tool/auth_helpers_test.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; +import { + applyCredential, + createApiKeyScheme, + createBearerScheme, +} from '../../../src/tools/openapi_tool/auth/auth_helpers.js'; + +describe('auth_helpers', () => { + describe('applyCredential', () => { + it('should return original url if no credential provided', () => { + const url = 'https://example.com'; + const headers = {}; + const result = applyCredential(url, headers, undefined); + expect(result).toBe(url); + expect(headers).toEqual({}); + }); + + it('should apply API key in header', () => { + const url = 'https://example.com'; + const headers: Record = {}; + const credential = {api_key: 'my-key'}; + const authScheme = {in: 'header', name: 'X-API-Key'}; + + const result = applyCredential(url, headers, credential, authScheme); + + expect(result).toBe(url); + expect(headers['X-API-Key']).toBe('my-key'); + }); + + it('should apply API key in query', () => { + const url = 'https://example.com'; + const headers: Record = {}; + const credential = {api_key: 'my-key'}; + const authScheme = {in: 'query', name: 'key'}; + + const result = applyCredential(url, headers, credential, authScheme); + + expect(result).toBe('https://example.com?key=my-key'); + expect(headers).toEqual({}); + }); + + it('should apply API key in query with existing params', () => { + const url = 'https://example.com?existing=param'; + const headers: Record = {}; + const credential = {api_key: 'my-key'}; + const authScheme = {in: 'query', name: 'key'}; + + const result = applyCredential(url, headers, credential, authScheme); + + expect(result).toBe('https://example.com?existing=param&key=my-key'); + }); + + it('should apply bearer token', () => { + const url = 'https://example.com'; + const headers: Record = {}; + const credential = { + http: { + credentials: { + token: 'my-token', + }, + }, + }; + + const result = applyCredential(url, headers, credential); + + expect(result).toBe(url); + expect(headers['Authorization']).toBe('Bearer my-token'); + }); + }); + + describe('helpers', () => { + it('should create API key scheme', () => { + const scheme = createApiKeyScheme('X-API-Key', 'header'); + expect(scheme).toEqual({ + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }); + }); + + it('should create bearer scheme', () => { + const scheme = createBearerScheme(); + expect(scheme).toEqual({ + type: 'http', + scheme: 'bearer', + }); + }); + }); +}); diff --git a/core/test/tools/openapi_tool/credential_exchangers_test.ts b/core/test/tools/openapi_tool/credential_exchangers_test.ts new file mode 100644 index 00000000..4d42bac4 --- /dev/null +++ b/core/test/tools/openapi_tool/credential_exchangers_test.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {JWT} from 'google-auth-library'; +import {describe, expect, it, vi} from 'vitest'; +import { + AuthCredential, + AuthCredentialTypes, +} from '../../../src/auth/auth_credential.js'; +import {AutoAuthCredentialExchanger} from '../../../src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.js'; +import {ServiceAccountCredentialExchanger} from '../../../src/tools/openapi_tool/auth/credential_exchangers/service_account_exchanger.js'; + +// Mock google-auth-library +vi.mock('google-auth-library', () => { + return { + JWT: vi.fn().mockImplementation(() => ({ + authorize: vi.fn().mockResolvedValue({access_token: 'mock-token'}), + })), + GoogleAuth: vi.fn().mockImplementation(() => ({ + getClient: vi.fn().mockResolvedValue({ + getAccessToken: vi.fn().mockResolvedValue({token: 'mock-adc-token'}), + }), + })), + }; +}); + +describe('AutoAuthCredentialExchanger', () => { + it('should return original credential if no exchanger registered', async () => { + const exchanger = new AutoAuthCredentialExchanger(); + const credential = {authType: AuthCredentialTypes.API_KEY, apiKey: 'key'}; + + const result = await exchanger.exchange({authCredential: credential}); + + expect(result.wasExchanged).toBe(false); + expect(result.credential).toEqual(credential); + }); + + it('should use ServiceAccountCredentialExchanger for serviceAccount', async () => { + const exchanger = new AutoAuthCredentialExchanger(); + const credential = { + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount: { + useDefaultCredential: true, + }, + }; + + const result = await exchanger.exchange({ + authCredential: credential as unknown as AuthCredential, + }); + + expect(result.wasExchanged).toBe(true); + expect(result.credential.http?.credentials.token).toBe('mock-adc-token'); + }); +}); + +describe('ServiceAccountCredentialExchanger', () => { + it('should throw if not service account credential', async () => { + const exchanger = new ServiceAccountCredentialExchanger(); + const credential = {authType: AuthCredentialTypes.API_KEY}; + + await expect( + exchanger.exchange({ + authCredential: credential as unknown as AuthCredential, + }), + ).rejects.toThrow( + 'Invalid credential type for ServiceAccountCredentialExchanger', + ); + }); + + it('should exchange with explicit keys', async () => { + const exchanger = new ServiceAccountCredentialExchanger(); + const credential = { + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount: { + serviceAccountCredential: { + clientEmail: 'test@example.com', + privateKey: 'key', + }, + }, + }; + + const result = await exchanger.exchange({ + authCredential: credential as unknown as AuthCredential, + }); + + expect(result.wasExchanged).toBe(true); + expect(result.credential.http?.credentials.token).toBe('mock-token'); + }); + + it('should exchange with default credentials', async () => { + const exchanger = new ServiceAccountCredentialExchanger(); + const credential = { + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount: { + useDefaultCredential: true, + }, + }; + + const result = await exchanger.exchange({ + authCredential: credential as unknown as AuthCredential, + }); + + expect(result.wasExchanged).toBe(true); + expect(result.credential.http?.credentials.token).toBe('mock-adc-token'); + }); + + it('should throw if explicit credentials missing', async () => { + const exchanger = new ServiceAccountCredentialExchanger(); + const credential = { + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount: { + useDefaultCredential: false, + }, + }; + + await expect( + exchanger.exchange({ + authCredential: credential as unknown as AuthCredential, + }), + ).rejects.toThrow('Service account credentials are missing.'); + }); + + it('should throw if token exchange fails (missing token)', async () => { + const exchanger = new ServiceAccountCredentialExchanger(); + const credential = { + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount: { + serviceAccountCredential: { + clientEmail: 'test@example.com', + privateKey: 'key', + }, + }, + }; + + const mockJWT = vi.mocked(JWT); + mockJWT.mockImplementationOnce( + () => + ({ + authorize: vi.fn().mockResolvedValue({}), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + await expect( + exchanger.exchange({ + authCredential: credential as unknown as AuthCredential, + }), + ).rejects.toThrow( + 'Failed to exchange explicit service account token: Failed to get access token from explicit credentials', + ); + }); + + it('should throw if token exchange throws error', async () => { + const exchanger = new ServiceAccountCredentialExchanger(); + const credential = { + authType: AuthCredentialTypes.SERVICE_ACCOUNT, + serviceAccount: { + serviceAccountCredential: { + clientEmail: 'test@example.com', + privateKey: 'key', + }, + }, + }; + + const mockJWT = vi.mocked(JWT); + mockJWT.mockImplementationOnce( + () => + ({ + authorize: vi.fn().mockRejectedValue(new Error('Auth failed')), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + await expect( + exchanger.exchange({ + authCredential: credential as unknown as AuthCredential, + }), + ).rejects.toThrow( + 'Failed to exchange explicit service account token: Auth failed', + ); + }); +}); diff --git a/core/test/tools/openapi_tool/fixtures/petstore.yaml b/core/test/tools/openapi_tool/fixtures/petstore.yaml new file mode 100644 index 00000000..487d8251 --- /dev/null +++ b/core/test/tools/openapi_tool/fixtures/petstore.yaml @@ -0,0 +1,841 @@ +openapi: 3.0.4 +info: + title: Swagger Petstore - OpenAPI 3.0 + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about + Swagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + + Some useful links: + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + termsOfService: https://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.27 +externalDocs: + description: Find out more about Swagger + url: https://swagger.io +servers: + - url: https://petstore3.swagger.io/api/v3 +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: https://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: https://swagger.io + - name: user + description: Operations about user +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet. + description: Update an existing pet by Id. + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '422': + description: Validation exception + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store. + description: Add a new pet to the store. + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid input + '422': + description: Validation exception + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status. + description: Multiple status values can be provided with comma separated strings. + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + explode: true + schema: + type: string + default: available + enum: + - available + - pending + - sold + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags. + description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + explode: true + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid tag value + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: + tags: + - pet + summary: Find pet by ID. + description: Returns a single pet. + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + default: + description: Unexpected error + security: + - api_key: [] + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Updates a pet in the store with form data. + description: Updates a pet resource based on the form data. + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid input + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet. + description: Delete a pet. + operationId: deletePet + parameters: + - name: api_key + in: header + description: '' + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Pet deleted + '400': + description: Invalid pet value + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: Uploads an image. + description: Upload image of the pet. + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + required: false + schema: + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + '400': + description: No file uploaded + '404': + description: Pet not found + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status. + description: Returns a map of status codes to quantities. + operationId: getInventory + x-swagger-router-controller: OrderController + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + default: + description: Unexpected error + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet. + description: Place a new order in the store. + x-swagger-router-controller: OrderController + operationId: placeOrder + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Order' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid input + '422': + description: Validation exception + default: + description: Unexpected error + /store/order/{orderId}: + get: + tags: + - store + summary: Find purchase order by ID. + x-swagger-router-controller: OrderController + description: For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/xml: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + default: + description: Unexpected error + delete: + tags: + - store + summary: Delete purchase order by identifier. + description: For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors. + x-swagger-router-controller: OrderController + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: order deleted + '400': + description: Invalid ID supplied + '404': + description: Order not found + default: + description: Unexpected error + /user: + post: + tags: + - user + summary: Create user. + description: This can only be done by the logged in user. + x-swagger-router-controller: UserController + operationId: createUser + requestBody: + description: Created user object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + default: + description: Unexpected error + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array. + description: Creates list of users with given input array. + x-swagger-router-controller: UserController + operationId: createUsersWithListInput + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + default: + description: Unexpected error + /user/login: + get: + tags: + - user + summary: Logs user into the system. + description: Log into the system. + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: false + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: false + schema: + type: string + responses: + '200': + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + default: + description: Unexpected error + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session. + description: Log user out of the system. + operationId: logoutUser + parameters: [] + responses: + '200': + description: successful operation + default: + description: Unexpected error + /user/{username}: + get: + tags: + - user + summary: Get user by user name. + description: Get user detail based on username. + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + default: + description: Unexpected error + put: + tags: + - user + summary: Update user resource. + description: This can only be done by the logged in user. + x-swagger-router-controller: UserController + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: successful operation + '400': + description: bad request + '404': + description: user not found + default: + description: Unexpected error + delete: + tags: + - user + summary: Delete user resource. + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '200': + description: User deleted + '400': + description: Invalid username supplied + '404': + description: User not found + default: + description: Unexpected error +components: + schemas: + Order: + x-swagger-router-model: io.swagger.petstore.model.Order + type: object + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + example: approved + enum: + - placed + - approved + - delivered + complete: + type: boolean + xml: + name: order + Category: + x-swagger-router-model: io.swagger.petstore.model.Category + type: object + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category + User: + x-swagger-router-model: io.swagger.petstore.model.User + type: object + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + example: john@email.com + password: + type: string + example: '12345' + phone: + type: string + example: '12345' + userStatus: + type: integer + description: User Status + format: int32 + example: 1 + xml: + name: user + Tag: + x-swagger-router-model: io.swagger.petstore.model.Tag + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + Pet: + x-swagger-router-model: io.swagger.petstore.model.Pet + required: + - name + - photoUrls + type: object + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + $ref: '#/components/schemas/Category' + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: '##default' + requestBodies: + Pet: + description: Pet object that needs to be added to the store + +content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + UserArray: + description: List of user object + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + "write:pets": modify pets in your account + "read:pets": read your pets + api_key: + type: apiKey + name: api_key + in: header + diff --git a/core/test/tools/openapi_tool/fixtures/truanon.yaml b/core/test/tools/openapi_tool/fixtures/truanon.yaml new file mode 100644 index 00000000..e51c6e0b --- /dev/null +++ b/core/test/tools/openapi_tool/fixtures/truanon.yaml @@ -0,0 +1,86 @@ +openapi: 3.0.3 +servers: + - url: https://staging.truanon.com +info: + contact: {} + description: |- + Welcome to TruAnon! + Thank you for helping make the Internet a safer place to be. + + Adopting TruAnon is simple. There is no setup or dependencies, nothing to store or process. Making identity part of your service is fun, and you’ll be up and running in a matter of minutes. + + TruAnon Private Token is used anytime you request information from TruAnon and you must edit this into the Variables section for this collection. + + This API contains two endpoints and both require these same two arguments, also found in the Variables section of this collection. + + These two arguments are: + + TruAnon Service Identifier + + and + + Your Member Name + + Your TruAnon Service Identifier was provided by TruAnon and is likely the root domain of your site or service. Your Member Name is the unique member ID on your system that you would like to query. + + Information is continuously updated for display purposes and aside from performance considerations, there should be no need to capture or save anything from TruAnon. + title: TruAnon Private API + version: 1.0.0 + x-apisguru-categories: + - security + x-origin: + - format: postman + url: https://www.postman.com/collections/097655c06fff1bf6a966 + version: 2.x + x-providerName: truanon.com +tags: [] +paths: + /api/get_profile: + get: + description: "get_profile Private API: Request confirmed profile data for your unique member ID" + operationId: getProfile + parameters: + - description: This is your unique username or member ID + in: query + name: id + schema: + example: "{{your-member-id}}" + type: string + - description: The service name given to you by TruAnon + in: query + name: service + schema: + example: "{{service-identifier}}" + type: string + responses: + "200": + description: "" + summary: Get Profile + /api/request_token: + get: + description: |- + request_token Private API: Request a Proof token to let the member confirm in a popup interface + + {"id":"qjgblv72bzzio","type":"Proof","active":true,"name":"New Proof"} + + Step 2. Create a verifyProfile Public Web link: Use the Proof token id as the token argument in your public URL used to open a new target popup. This activity is where members may confirm immediately. + + https://staging.truanon.com/verifyProfile?id=john_doe&service=securecannabisalliance&token=qjgblv72bzzio + operationId: getToken + parameters: + - description: This is your unique username or member ID + in: query + name: id + schema: + example: "{{your-member-id}}" + type: string + - description: The service name given to you by TruAnon + in: query + name: service + schema: + example: "{{service-identifier}}" + type: string + responses: + "200": + description: "" + summary: Get Token diff --git a/core/test/tools/openapi_tool/openapi_toolset_integration_test.ts b/core/test/tools/openapi_tool/openapi_toolset_integration_test.ts new file mode 100644 index 00000000..38ab7d84 --- /dev/null +++ b/core/test/tools/openapi_tool/openapi_toolset_integration_test.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {Context} from '../../../src/agents/context.js'; +import {OpenAPIToolset} from '../../../src/tools/openapi_tool/openapi_toolset.js'; + +describe('OpenAPIToolset Integration', () => { + let truanonSpec: string; + + beforeEach(() => { + const specPath = path.resolve(__dirname, 'fixtures/truanon.yaml'); + truanonSpec = fs.readFileSync(specPath, 'utf8'); + + // Mock global fetch + globalThis.fetch = vi.fn(); + }); + + it('should parse truanon spec and create tools', async () => { + const toolset = new OpenAPIToolset({ + specStr: truanonSpec, + specType: 'yaml', + }); + const tools = await toolset.getTools(); + + const toolNames = tools.map((t) => t.name); + expect(toolNames).toContain('get_profile'); + expect(toolNames).toContain('get_token'); + }); + + it('should execute a tool with mocked fetch', async () => { + const toolset = new OpenAPIToolset({ + specStr: truanonSpec, + specType: 'yaml', + }); + const tools = await toolset.getTools(); + const getProfileTool = tools.find((t) => t.name === 'get_profile'); + + expect(getProfileTool).toBeTruthy(); + + const mockResponse = {status: 'success', data: {confirmed: true}}; + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + headers: {get: () => 'application/json'}, + json: async () => mockResponse, + }); + + // Mock context + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + const result = await getProfileTool!.runAsync({ + args: {id: 'user1', service: 'myservice'}, + toolContext: mockContext as unknown as Context, + }); + + expect(result).toEqual(mockResponse); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://staging.truanon.com/api/get_profile?id=user1&service=myservice', + expect.objectContaining({ + method: 'GET', + }), + ); + }); + + it('should handle non-JSON response', async () => { + const toolset = new OpenAPIToolset({ + specStr: truanonSpec, + specType: 'yaml', + }); + const tools = await toolset.getTools(); + const getProfileTool = tools.find((t) => t.name === 'get_profile'); + + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'plain text response', + }); + + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + const result = await getProfileTool!.runAsync({ + args: {id: 'user1', service: 'myservice'}, + toolContext: mockContext as unknown as Context, + }); + + expect(result).toBe('plain text response'); + }); + + it('should handle fetch error', async () => { + const toolset = new OpenAPIToolset({ + specStr: truanonSpec, + specType: 'yaml', + }); + const tools = await toolset.getTools(); + const getProfileTool = tools.find((t) => t.name === 'get_profile'); + + vi.mocked(globalThis.fetch).mockRejectedValue(new Error('Network error')); + + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + const result = await getProfileTool!.runAsync({ + args: {id: 'user1', service: 'myservice'}, + toolContext: mockContext as unknown as Context, + }); + + expect(result).toEqual({ + error: 'Failed to execute API call: Network error', + }); + }); +}); diff --git a/core/test/tools/openapi_tool/openapi_toolset_test.ts b/core/test/tools/openapi_tool/openapi_toolset_test.ts new file mode 100644 index 00000000..c406e05e --- /dev/null +++ b/core/test/tools/openapi_tool/openapi_toolset_test.ts @@ -0,0 +1,301 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {describe, expect, it} from 'vitest'; +import {OpenApiSpecParser} from '../../../src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.js'; +import {OpenAPIToolset} from '../../../src/tools/openapi_tool/openapi_toolset.js'; + +describe('OpenAPIToolset', () => { + const mockSpec: OpenAPIV3.Document = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0', + }, + servers: [{url: 'https://api.example.com'}], + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + parameters: [ + { + name: 'limit', + in: 'query', + description: 'Limit the number of users', + schema: {type: 'integer'}, + }, + ], + responses: { + '200': { + description: 'Successful response', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: {type: 'string'}, + name: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + post: { + operationId: 'createUser', + summary: 'Create user', + requestBody: { + description: 'User to create', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: {type: 'string'}, + }, + required: ['name'], + }, + }, + }, + }, + responses: { + '201': { + description: 'Created', + }, + }, + }, + }, + }, + }; + + it('should parse OpenAPI spec and create tools', async () => { + const toolset = new OpenAPIToolset({specDict: mockSpec}); + const tools = await toolset.getTools(); + + expect(tools.length).toBe(2); + expect(tools[0].name).toBe('get_users'); + expect(tools[1].name).toBe('create_user'); + }); + + it('should filter tools', async () => { + const toolset = new OpenAPIToolset({ + specDict: mockSpec, + toolFilter: ['get_users'], + }); + const tools = await toolset.getTools(); + + expect(tools.length).toBe(1); + expect(tools[0].name).toBe('get_users'); + }); + + it('should apply prefix', async () => { + const toolset = new OpenAPIToolset({ + specDict: mockSpec, + prefix: 'test', + }); + const tools = await toolset.getTools(); + + expect(tools.length).toBe(2); + expect(tools[0].name).toBe('test_get_users'); + expect(tools[1].name).toBe('test_create_user'); + }); + + it('should apply global auth overrides', async () => { + const toolset = new OpenAPIToolset({ + specDict: mockSpec, + authScheme: {type: 'apiKey', name: 'key', in: 'header'}, + authCredential: {api_key: 'my-key'}, + }); + const tools = await toolset.getTools(); + + expect(tools.length).toBe(2); + expect((tools[0] as unknown as Record).authScheme).toEqual( + {type: 'apiKey', name: 'key', in: 'header'}, + ); + expect( + (tools[0] as unknown as Record).authCredential, + ).toEqual({api_key: 'my-key'}); + }); +}); + +describe('OpenApiSpecParser', () => { + const mockSpec: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Test', version: '1.0'}, + paths: { + '/test': { + get: { + operationId: 'testOp', + responses: {'200': {description: 'OK'}}, + }, + }, + }, + }; + + it('should parse operations', () => { + const parser = new OpenApiSpecParser(); + const operations = parser.parse(mockSpec); + + expect(operations.length).toBe(1); + expect(operations[0].name).toBe('test_op'); + }); + + it('should resolve references', () => { + const specWithRef = { + openapi: '3.0.0', + info: {title: 'Test', version: '1.0'}, + paths: { + '/test': { + get: { + operationId: 'testOp', + parameters: [{$ref: '#/components/parameters/limit'}], + responses: {'200': {description: 'OK'}}, + }, + }, + }, + components: { + parameters: { + limit: { + name: 'limit', + in: 'query', + schema: {type: 'integer'}, + }, + }, + }, + } as unknown as OpenAPIV3.Document; + + const parser = new OpenApiSpecParser(); + const operations = parser.parse(specWithRef); + + expect(operations.length).toBe(1); + expect(operations[0].operation.parameters?.[0]).toEqual({ + name: 'limit', + in: 'query', + schema: {type: 'integer'}, + }); + }); + + it('should generate operationId if missing', () => { + const specMissingId = { + openapi: '3.0.0', + info: {title: 'Test', version: '1.0'}, + paths: { + '/test': { + get: { + responses: {'200': {description: 'OK'}}, + }, + }, + }, + } as unknown as OpenAPIV3.Document; + + const parser = new OpenApiSpecParser(); + const operations = parser.parse(specMissingId); + + expect(operations.length).toBe(1); + expect(operations[0].operation.operationId).toBe('get__test'); + }); + + it('should extract specific security scheme', () => { + const specWithSecurity = { + openapi: '3.0.0', + info: {title: 'Test', version: '1.0'}, + paths: { + '/test': { + get: { + operationId: 'testOp', + security: [{custom_auth: []}], + responses: {'200': {description: 'OK'}}, + }, + }, + }, + components: { + securitySchemes: { + custom_auth: { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }, + }, + }, + } as unknown as OpenAPIV3.Document; + + const parser = new OpenApiSpecParser(); + const operations = parser.parse(specWithSecurity); + + expect(operations.length).toBe(1); + expect(operations[0].authScheme).toEqual({ + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }); + }); + + it('should handle broken reference', () => { + const specWithBrokenRef = { + openapi: '3.0.0', + info: {title: 'Test', version: '1.0'}, + paths: { + '/test': { + get: { + operationId: 'testOp', + parameters: [{$ref: '#/components/parameters/nonexistent'}], + responses: {'200': {description: 'OK'}}, + }, + }, + }, + components: { + parameters: {}, + }, + } as unknown as OpenAPIV3.Document; + + const parser = new OpenApiSpecParser(); + const operations = parser.parse(specWithBrokenRef); + + expect(operations.length).toBe(1); + expect(operations[0].operation.parameters?.[0]).toEqual({ + $ref: '#/components/parameters/nonexistent', + }); + }); + + it('should handle global security', () => { + const specWithGlobalSecurity = { + openapi: '3.0.0', + info: {title: 'Test', version: '1.0'}, + security: [{global_auth: []}], + paths: { + '/test': { + get: { + operationId: 'testOp', + responses: {'200': {description: 'OK'}}, + }, + }, + }, + components: { + securitySchemes: { + global_auth: { + type: 'http', + scheme: 'bearer', + }, + }, + }, + } as unknown as OpenAPIV3.Document; + + const parser = new OpenApiSpecParser(); + const operations = parser.parse(specWithGlobalSecurity); + + expect(operations.length).toBe(1); + expect(operations[0].authScheme).toEqual({ + type: 'http', + scheme: 'bearer', + }); + }); +}); diff --git a/core/test/tools/openapi_tool/operation_parser_test.ts b/core/test/tools/openapi_tool/operation_parser_test.ts new file mode 100644 index 00000000..13554034 --- /dev/null +++ b/core/test/tools/openapi_tool/operation_parser_test.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {describe, expect, it} from 'vitest'; +import {OperationParser} from '../../../src/tools/openapi_tool/openapi_spec_parser/operation_parser.js'; + +describe('OperationParser', () => { + it('should throw error if operationId is missing', () => { + const op: OpenAPIV3.OperationObject = { + responses: {}, + }; + const parser = new OperationParser(op); + expect(() => parser.getFunctionName()).toThrow('Operation ID is missing'); + }); + + it('should parse array request body', () => { + const op: OpenAPIV3.OperationObject = { + operationId: 'testOp', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'array', + items: {type: 'string'}, + }, + }, + }, + }, + responses: {}, + }; + + const parser = new OperationParser(op); + const params = parser.getParameters(); + + expect(params.length).toBe(1); + expect(params[0].name).toBe('body'); + expect(params[0].paramLocation).toBe('body'); + expect(params[0].paramSchema.type).toBe('array'); + }); + + it('should parse primitive request body', () => { + const op: OpenAPIV3.OperationObject = { + operationId: 'testOp', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'string', + }, + }, + }, + }, + responses: {}, + }; + + const parser = new OperationParser(op); + const params = parser.getParameters(); + + expect(params.length).toBe(1); + expect(params[0].name).toBe('body'); + expect(params[0].paramLocation).toBe('body'); + expect(params[0].paramSchema.type).toBe('string'); + }); + + it('should parse response schema', () => { + const op: OpenAPIV3.OperationObject = { + operationId: 'testOp', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + id: {type: 'integer'}, + }, + }, + }, + }, + }, + }, + }; + + const parser = new OperationParser(op); + const schema = parser.getJsonSchema(); + + expect(schema).toBeTruthy(); + expect(schema.title).toBe('testOp_Arguments'); + }); +}); diff --git a/core/test/tools/openapi_tool/rest_api_tool_test.ts b/core/test/tools/openapi_tool/rest_api_tool_test.ts new file mode 100644 index 00000000..876e3953 --- /dev/null +++ b/core/test/tools/openapi_tool/rest_api_tool_test.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {OpenAPIV3} from 'openapi-types'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {Context} from '../../../src/agents/context.js'; +import {RestApiTool} from '../../../src/tools/openapi_tool/rest_api_tool.js'; + +describe('RestApiTool', () => { + const mockOperation: OpenAPIV3.OperationObject = { + operationId: 'createUser', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + name: {type: 'string'}, + }, + }, + }, + }, + }, + responses: { + '200': {description: 'OK'}, + }, + }; + + beforeEach(() => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'application/json'}, + json: async () => ({success: true}), + }); + }); + + it('should handle request body in execution', async () => { + const tool = new RestApiTool( + 'create_user', + 'Create a user', + {baseUrl: 'https://api.example.com', path: '/users', method: 'POST'}, + mockOperation, + ); + + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + const result = await tool.runAsync({ + args: {name: 'John Doe'}, + toolContext: mockContext as unknown as Context, + }); + + expect(result).toEqual({success: true}); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.example.com/users', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({name: 'John Doe'}), + }), + ); + }); + + it('should handle path parameters', async () => { + const opWithPathParam: OpenAPIV3.OperationObject = { + operationId: 'getUser', + parameters: [ + { + name: 'userId', + in: 'path', + required: true, + schema: {type: 'string'}, + }, + ], + responses: {'200': {description: 'OK'}}, + }; + + const tool = new RestApiTool( + 'get_user', + 'Get a user', + { + baseUrl: 'https://api.example.com', + path: '/users/{userId}', + method: 'GET', + }, + opWithPathParam, + ); + + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + await tool.runAsync({ + args: {user_id: '123'}, + toolContext: mockContext as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.example.com/users/123', + expect.objectContaining({ + method: 'GET', + }), + ); + }); + + it('should handle header parameters', async () => { + const opWithHeaderParam: OpenAPIV3.OperationObject = { + operationId: 'testOp', + parameters: [ + { + name: 'X-Custom-Header', + in: 'header', + required: true, + schema: {type: 'string'}, + }, + ], + responses: {'200': {description: 'OK'}}, + }; + + const tool = new RestApiTool( + 'test_op', + 'Test Op', + {baseUrl: 'https://api.example.com', path: '/test', method: 'GET'}, + opWithHeaderParam, + undefined, + undefined, + {preservePropertyNames: true}, + ); + + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + await tool.runAsync({ + args: {'X-Custom-Header': 'my-value'}, + toolContext: mockContext as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.example.com/test', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Custom-Header': 'my-value', + }), + }), + ); + }); + + it('should handle explicit body parameter', async () => { + const opWithExplicitBody: OpenAPIV3.OperationObject = { + operationId: 'testOp', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + }, + }, + }, + }, + responses: {'200': {description: 'OK'}}, + }; + + const tool = new RestApiTool( + 'test_op', + 'Test Op', + {baseUrl: 'https://api.example.com', path: '/test', method: 'POST'}, + opWithExplicitBody, + ); + + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + const bodyObj = {some: 'data'}; + await tool.runAsync({ + args: {body: bodyObj}, + toolContext: mockContext as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.example.com/test', + expect.objectContaining({ + body: JSON.stringify(bodyObj), + }), + ); + }); + + it('should return declaration', () => { + const tool = new RestApiTool( + 'create_user', + 'Create a user', + {baseUrl: 'https://api.example.com', path: '/users', method: 'POST'}, + mockOperation, + ); + + const declaration = tool._getDeclaration(); + expect(declaration.name).toBe('create_user'); + expect(declaration.description).toBe('Create a user'); + expect(declaration.parameters).toBeTruthy(); + }); + + it('should return pending state when auth is pending', async () => { + const tool = new RestApiTool( + 'test_op', + 'Test Op', + {baseUrl: 'https://api.example.com', path: '/test', method: 'GET'}, + {operationId: 'testOp', responses: {}}, + {type: 'apiKey', in: 'header', name: 'key'}, + ); + + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + const result = await tool.runAsync({ + args: {}, + toolContext: mockContext as unknown as Context, + }); + + expect(result).toEqual({ + pending: true, + message: 'Needs your authorization to access your data.', + }); + }); + + it('should apply headers from headerProvider', async () => { + const headerProvider = vi + .fn() + .mockReturnValue({'X-Dynamic-Header': 'dynamic-value'}); + const tool = new RestApiTool( + 'test_op', + 'Test Op', + {baseUrl: 'https://api.example.com', path: '/test', method: 'GET'}, + {operationId: 'testOp', responses: {}}, + undefined, + undefined, + {headerProvider}, + ); + + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + state: {}, + }; + + await tool.runAsync({ + args: {}, + toolContext: mockContext as unknown as Context, + }); + + expect(headerProvider).toHaveBeenCalled(); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://api.example.com/test', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Dynamic-Header': 'dynamic-value', + }), + }), + ); + }); +}); diff --git a/core/test/tools/openapi_tool/tool_auth_handler_test.ts b/core/test/tools/openapi_tool/tool_auth_handler_test.ts new file mode 100644 index 00000000..a8badd6e --- /dev/null +++ b/core/test/tools/openapi_tool/tool_auth_handler_test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it, vi} from 'vitest'; +import {Context} from '../../../src/agents/context.js'; +import {AuthCredentialTypes} from '../../../src/auth/auth_credential.js'; +import {ToolAuthHandler} from '../../../src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.js'; + +// Mock AutoAuthCredentialExchanger +vi.mock( + '../../../src/tools/openapi_tool/auth/credential_exchangers/auto_auth_credential_exchanger.js', + () => { + return { + AutoAuthCredentialExchanger: vi.fn().mockImplementation(() => ({ + exchange: vi.fn().mockResolvedValue({ + credential: { + authType: AuthCredentialTypes.HTTP, + http: {scheme: 'bearer', credentials: {token: 'exchanged-token'}}, + }, + wasExchanged: true, + }), + })), + }; + }, +); + +describe('ToolAuthHandler', () => { + it('should return done if no auth scheme', async () => { + const mockContext = {} as unknown as Context; + const handler = new ToolAuthHandler(mockContext); + + const result = await handler.prepareAuthCredentials(); + + expect(result.state).toBe('done'); + expect(result.authCredential).toBeUndefined(); + }); + + it('should return done after exchange if credential in context', async () => { + const mockContext = { + getAuthResponse: vi + .fn() + .mockReturnValue({ + authType: AuthCredentialTypes.API_KEY, + apiKey: 'key', + }), + } as unknown as Context; + + const handler = new ToolAuthHandler(mockContext, {type: 'apiKey'}); + + const result = await handler.prepareAuthCredentials(); + + expect(result.state).toBe('done'); + expect(result.authCredential?.http?.credentials.token).toBe( + 'exchanged-token', + ); + }); + + it('should return pending and request credential if not in context', async () => { + const mockContext = { + getAuthResponse: vi.fn().mockReturnValue(undefined), + requestCredential: vi.fn(), + } as unknown as Context; + + const handler = new ToolAuthHandler(mockContext, {type: 'apiKey'}); + + const result = await handler.prepareAuthCredentials(); + + expect(result.state).toBe('pending'); + expect(mockContext.requestCredential).toHaveBeenCalled(); + }); +}); From 8f503f9f42f4abc0ce1a658c23c91b39aedf3cd5 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 23 Apr 2026 11:46:09 -0700 Subject: [PATCH 2/8] fix(openapi): resolve type errors and remove any types --- .../tools/openapi_tool/auth/auth_helpers.ts | 20 +++++++---- .../openapi_spec_parser.ts | 34 +++++++++++-------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/core/src/tools/openapi_tool/auth/auth_helpers.ts b/core/src/tools/openapi_tool/auth/auth_helpers.ts index 714a7598..b000a545 100644 --- a/core/src/tools/openapi_tool/auth/auth_helpers.ts +++ b/core/src/tools/openapi_tool/auth/auth_helpers.ts @@ -19,23 +19,29 @@ import {AuthCredential} from '../../../auth/auth_credential.js'; export function applyCredential( url: string, headers: Record, - credential: AuthCredential, + credential?: AuthCredential, authScheme?: OpenAPIV3.SecuritySchemeObject, ): string { if (!credential) return url; - if (credential.api_key) { - const inLocation = authScheme?.in; - const name = authScheme?.name || 'key'; + if (credential.apiKey) { + let inLocation: string | undefined; + let name = 'key'; + + if (authScheme && authScheme.type === 'apiKey') { + const apiKeyScheme = authScheme as OpenAPIV3.ApiKeySecurityScheme; + inLocation = apiKeyScheme.in; + name = apiKeyScheme.name; + } if (inLocation === 'header') { - headers[name] = credential.api_key; + headers[name] = credential.apiKey; } else if (inLocation === 'query') { const separator = url.includes('?') ? '&' : '?'; - url += `${separator}${name}=${encodeURIComponent(credential.api_key)}`; + url += `${separator}${name}=${encodeURIComponent(credential.apiKey)}`; } else { // Default to header Authorization if not specified or unknown location - headers['Authorization'] = credential.api_key; + headers['Authorization'] = credential.apiKey; } } else if ( credential.http && diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts index ae109ea1..4e7ed901 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts @@ -43,17 +43,23 @@ export class OpenApiSpecParser { const resolvedCache = new Map(); const specCopy = JSON.parse(JSON.stringify(spec)); // Deep copy - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resolveRef = (refString: string, currentDoc: any) => { + const resolveRef = ( + refString: string, + currentDoc: OpenAPIV3.Document, + ): unknown => { const parts = refString.split('/'); if (parts[0] !== '#') { throw new Error(`External references not supported: ${refString}`); } - let current = currentDoc; + let current: unknown = currentDoc; for (const part of parts.slice(1)) { - if (part in current) { - current = current[part]; + if ( + typeof current === 'object' && + current !== null && + part in current + ) { + current = (current as Record)[part]; } else { return undefined; } @@ -61,12 +67,11 @@ export class OpenApiSpecParser { return current; }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any const recursiveResolve = ( - obj: any, - currentDoc: any, + obj: unknown, + currentDoc: OpenAPIV3.Document, seenRefs = new Set(), - ): any => { + ): unknown => { if (typeof obj !== 'object' || obj === null) { return obj; } @@ -75,12 +80,13 @@ export class OpenApiSpecParser { return obj.map((item) => recursiveResolve(item, currentDoc, seenRefs)); } - if ('$ref' in obj && typeof obj['$ref'] === 'string') { - const refString = obj['$ref']; + const objRecord = obj as Record; + if ('$ref' in objRecord && typeof objRecord['$ref'] === 'string') { + const refString = objRecord['$ref'] as string; if (seenRefs.has(refString) && !resolvedCache.has(refString)) { // Circular reference detected. Break cycle. - const copy = {...obj}; + const copy = {...objRecord}; delete copy['$ref']; return copy; } @@ -102,13 +108,13 @@ export class OpenApiSpecParser { } const newDict: Record = {}; - for (const [key, value] of Object.entries(obj)) { + for (const [key, value] of Object.entries(objRecord)) { newDict[key] = recursiveResolve(value, currentDoc, seenRefs); } return newDict; }; - return recursiveResolve(specCopy, specCopy); + return recursiveResolve(specCopy, specCopy) as OpenAPIV3.Document; } private collectOperations(spec: OpenAPIV3.Document): ParsedOperation[] { From a498ba2223e3034d56d97dcccd75393b783fd548 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 23 Apr 2026 11:52:12 -0700 Subject: [PATCH 3/8] feat(openapi): align RestApiTool with Python implementation --- core/src/tools/openapi_tool/rest_api_tool.ts | 66 +++++++++++++++++--- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/core/src/tools/openapi_tool/rest_api_tool.ts b/core/src/tools/openapi_tool/rest_api_tool.ts index 24ef1a0d..a43992bf 100644 --- a/core/src/tools/openapi_tool/rest_api_tool.ts +++ b/core/src/tools/openapi_tool/rest_api_tool.ts @@ -96,8 +96,7 @@ export class RestApiTool extends BaseTool { return { name: this.name, description: this.description, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - parameters: schema as any, // Cast to any if types don't match exactly + parameters: schema, }; } @@ -126,9 +125,7 @@ export class RestApiTool extends BaseTool { const method = this.endpoint.method.toUpperCase(); let url = `${this.endpoint.baseUrl}${this.endpoint.path}`; - const headers: Record = { - 'Content-Type': 'application/json', - }; + const headers: Record = {}; const queryParams = new URLSearchParams(); let body: unknown = undefined; @@ -170,6 +167,16 @@ export class RestApiTool extends BaseTool { url = url.replace(`{${key}}`, value); } + // Extract query parameters from path if any + const urlParts = url.split('?'); + if (urlParts.length > 1) { + const pathQueryParams = new URLSearchParams(urlParts[1]); + for (const [key, value] of pathQueryParams.entries()) { + queryParams.append(key, value); + } + url = urlParts[0]; + } + // Append query parameters const queryString = queryParams.toString(); if (queryString) { @@ -177,10 +184,48 @@ export class RestApiTool extends BaseTool { } // Handle body - if (body === undefined && Object.keys(bodyData).length > 0) { - body = JSON.stringify(bodyData); - } else if (body !== undefined && typeof body !== 'string') { - body = JSON.stringify(body); + const requestBody = this.operation.requestBody; + const finalData = + body !== undefined + ? body + : Object.keys(bodyData).length > 0 + ? bodyData + : undefined; + + if (requestBody && 'content' in requestBody) { + const content = requestBody.content; + for (const [mimeType, _mediaTypeObject] of Object.entries(content)) { + if (finalData !== undefined) { + if (mimeType === 'application/json' || mimeType.endsWith('+json')) { + body = + typeof finalData === 'string' + ? finalData + : JSON.stringify(finalData); + headers['Content-Type'] = mimeType; + } else if (mimeType === 'application/x-www-form-urlencoded') { + body = new URLSearchParams(finalData as Record); + // Fetch sets content-type automatically for URLSearchParams + } else if (mimeType === 'multipart/form-data') { + const formData = new FormData(); + if (typeof finalData === 'object' && finalData !== null) { + for (const [key, value] of Object.entries(finalData)) { + formData.append(key, String(value)); + } + } + body = formData; + // Fetch sets content-type with boundary automatically. DO NOT set it. + } else if (mimeType === 'text/plain') { + body = String(finalData); + headers['Content-Type'] = mimeType; + } + } + break; // Process only the first mime type + } + } else if (finalData !== undefined) { + // Fallback to JSON if no requestBody content specified but data exists + body = + typeof finalData === 'string' ? finalData : JSON.stringify(finalData); + headers['Content-Type'] = 'application/json'; } // Handle Auth @@ -196,7 +241,8 @@ export class RestApiTool extends BaseTool { const response = await globalThis.fetch(url, { method, headers, - body, + // eslint-disable-next-line no-undef + body: body as BodyInit, }); const contentType = response.headers.get('content-type'); From b6c84c1c55b00fb03e832ebd45f63158b3463913 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 23 Apr 2026 11:52:40 -0700 Subject: [PATCH 4/8] test(openapi): add unit tests and improve coverage --- .../tools/openapi_tool/auth_helpers_test.ts | 83 ++- .../openapi_tool/openapi_toolset_test.ts | 20 + .../tools/openapi_tool/rest_api_tool_test.ts | 552 ++++++++++++------ 3 files changed, 442 insertions(+), 213 deletions(-) diff --git a/core/test/tools/openapi_tool/auth_helpers_test.ts b/core/test/tools/openapi_tool/auth_helpers_test.ts index b19f7469..9e79895d 100644 --- a/core/test/tools/openapi_tool/auth_helpers_test.ts +++ b/core/test/tools/openapi_tool/auth_helpers_test.ts @@ -1,10 +1,6 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - +import {OpenAPIV3} from 'openapi-types'; import {describe, expect, it} from 'vitest'; +import {AuthCredential} from '../../../src/auth/auth_credential.js'; import { applyCredential, createApiKeyScheme, @@ -13,8 +9,8 @@ import { describe('auth_helpers', () => { describe('applyCredential', () => { - it('should return original url if no credential provided', () => { - const url = 'https://example.com'; + it('should return original URL if credential is not provided', () => { + const url = 'http://example.com'; const headers = {}; const result = applyCredential(url, headers, undefined); expect(result).toBe(url); @@ -22,47 +18,70 @@ describe('auth_helpers', () => { }); it('should apply API key in header', () => { - const url = 'https://example.com'; + const url = 'http://example.com'; const headers: Record = {}; - const credential = {api_key: 'my-key'}; - const authScheme = {in: 'header', name: 'X-API-Key'}; + const credential: AuthCredential = {apiKey: 'secret_key'}; + const authScheme: OpenAPIV3.SecuritySchemeObject = { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }; const result = applyCredential(url, headers, credential, authScheme); expect(result).toBe(url); - expect(headers['X-API-Key']).toBe('my-key'); + expect(headers['X-API-Key']).toBe('secret_key'); }); it('should apply API key in query', () => { - const url = 'https://example.com'; + const url = 'http://example.com'; const headers: Record = {}; - const credential = {api_key: 'my-key'}; - const authScheme = {in: 'query', name: 'key'}; + const credential: AuthCredential = {apiKey: 'secret_key'}; + const authScheme: OpenAPIV3.SecuritySchemeObject = { + type: 'apiKey', + name: 'api_key', + in: 'query', + }; const result = applyCredential(url, headers, credential, authScheme); - expect(result).toBe('https://example.com?key=my-key'); + expect(result).toBe('http://example.com?api_key=secret_key'); expect(headers).toEqual({}); }); it('should apply API key in query with existing params', () => { - const url = 'https://example.com?existing=param'; + const url = 'http://example.com?foo=bar'; const headers: Record = {}; - const credential = {api_key: 'my-key'}; - const authScheme = {in: 'query', name: 'key'}; + const credential: AuthCredential = {apiKey: 'secret_key'}; + const authScheme: OpenAPIV3.SecuritySchemeObject = { + type: 'apiKey', + name: 'api_key', + in: 'query', + }; const result = applyCredential(url, headers, credential, authScheme); - expect(result).toBe('https://example.com?existing=param&key=my-key'); + expect(result).toBe('http://example.com?foo=bar&api_key=secret_key'); + }); + + it('should fallback to Authorization header for API key if location is not specified', () => { + const url = 'http://example.com'; + const headers: Record = {}; + const credential: AuthCredential = {apiKey: 'secret_key'}; + + const result = applyCredential(url, headers, credential); + + expect(result).toBe(url); + expect(headers['Authorization']).toBe('secret_key'); }); it('should apply bearer token', () => { - const url = 'https://example.com'; + const url = 'http://example.com'; const headers: Record = {}; - const credential = { + const credential: AuthCredential = { http: { credentials: { - token: 'my-token', + token: 'my_token', }, }, }; @@ -70,23 +89,25 @@ describe('auth_helpers', () => { const result = applyCredential(url, headers, credential); expect(result).toBe(url); - expect(headers['Authorization']).toBe('Bearer my-token'); + expect(headers['Authorization']).toBe('Bearer my_token'); }); }); - describe('helpers', () => { - it('should create API key scheme', () => { - const scheme = createApiKeyScheme('X-API-Key', 'header'); - expect(scheme).toEqual({ + describe('createApiKeyScheme', () => { + it('should create an API key scheme', () => { + const result = createApiKeyScheme('X-API-Key', 'header'); + expect(result).toEqual({ type: 'apiKey', name: 'X-API-Key', in: 'header', }); }); + }); - it('should create bearer scheme', () => { - const scheme = createBearerScheme(); - expect(scheme).toEqual({ + describe('createBearerScheme', () => { + it('should create a bearer scheme', () => { + const result = createBearerScheme(); + expect(result).toEqual({ type: 'http', scheme: 'bearer', }); diff --git a/core/test/tools/openapi_tool/openapi_toolset_test.ts b/core/test/tools/openapi_tool/openapi_toolset_test.ts index c406e05e..231e70f0 100644 --- a/core/test/tools/openapi_tool/openapi_toolset_test.ts +++ b/core/test/tools/openapi_tool/openapi_toolset_test.ts @@ -6,6 +6,7 @@ import {OpenAPIV3} from 'openapi-types'; import {describe, expect, it} from 'vitest'; +import {ReadonlyContext} from '../../../src/agents/readonly_context.js'; import {OpenApiSpecParser} from '../../../src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.js'; import {OpenAPIToolset} from '../../../src/tools/openapi_tool/openapi_toolset.js'; @@ -125,6 +126,25 @@ describe('OpenAPIToolset', () => { (tools[0] as unknown as Record).authCredential, ).toEqual({api_key: 'my-key'}); }); + + it('should handle context in getTools', async () => { + const toolset = new OpenAPIToolset({specDict: mockSpec}); + const mockContext = {}; + ( + toolset as unknown as { + isToolSelected: (tool: unknown, context: unknown) => boolean; + } + ).isToolSelected = () => true; + const tools = await toolset.getTools( + mockContext as unknown as ReadonlyContext, + ); + expect(tools.length).toBe(2); + }); + + it('should call close', async () => { + const toolset = new OpenAPIToolset({specDict: mockSpec}); + await expect(toolset.close()).resolves.toBeUndefined(); + }); }); describe('OpenApiSpecParser', () => { 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 876e3953..762f2d52 100644 --- a/core/test/tools/openapi_tool/rest_api_tool_test.ts +++ b/core/test/tools/openapi_tool/rest_api_tool_test.ts @@ -1,275 +1,463 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - import {OpenAPIV3} from 'openapi-types'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {afterEach, describe, expect, it, vi} from 'vitest'; import {Context} from '../../../src/agents/context.js'; +import {ToolAuthHandler} from '../../../src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.js'; import {RestApiTool} from '../../../src/tools/openapi_tool/rest_api_tool.js'; describe('RestApiTool', () => { - const mockOperation: OpenAPIV3.OperationObject = { - operationId: 'createUser', - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - name: {type: 'string'}, - }, - }, - }, - }, - }, - responses: { - '200': {description: 'OK'}, - }, - }; - - beforeEach(() => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - headers: {get: () => 'application/json'}, - json: async () => ({success: true}), - }); + afterEach(() => { + vi.restoreAllMocks(); }); - it('should handle request body in execution', async () => { + it('should configure credential key', () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'GET', + }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; const tool = new RestApiTool( - 'create_user', - 'Create a user', - {baseUrl: 'https://api.example.com', path: '/users', method: 'POST'}, - mockOperation, + 'test_tool', + 'description', + endpoint, + operation, + ); + + tool.configureCredentialKey('my-credential-key'); + + expect((tool as unknown as {credentialKey: string}).credentialKey).toBe( + 'my-credential-key', ); + }); - const mockContext = { - getAuthResponse: vi.fn().mockReturnValue(undefined), - requestCredential: vi.fn(), - state: {}, + it('should apply headers from provider', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'GET', }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; + const headerProvider = vi + .fn() + .mockReturnValue({'X-Custom-Header': 'custom-value'}); + const tool = new RestApiTool( + 'test_tool', + 'description', + endpoint, + operation, + undefined, + undefined, + {headerProvider}, + ); - const result = await tool.runAsync({ - args: {name: 'John Doe'}, + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + const mockContext = {}; + await tool.runAsync({ + args: {}, toolContext: mockContext as unknown as Context, }); - expect(result).toEqual({success: true}); + expect(headerProvider).toHaveBeenCalledWith(mockContext); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://api.example.com/users', + expect.anything(), expect.objectContaining({ - method: 'POST', - body: JSON.stringify({name: 'John Doe'}), + headers: expect.objectContaining({'X-Custom-Header': 'custom-value'}), }), ); }); - it('should handle path parameters', async () => { - const opWithPathParam: OpenAPIV3.OperationObject = { - operationId: 'getUser', - parameters: [ - { - name: 'userId', - in: 'path', - required: true, - schema: {type: 'string'}, - }, - ], - responses: {'200': {description: 'OK'}}, + it('should stringify object body', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'POST', }; - + const operation: OpenAPIV3.OperationObject = {responses: {}}; const tool = new RestApiTool( - 'get_user', - 'Get a user', - { - baseUrl: 'https://api.example.com', - path: '/users/{userId}', - method: 'GET', - }, - opWithPathParam, + 'test_tool', + 'description', + endpoint, + operation, ); - const mockContext = { - getAuthResponse: vi.fn().mockReturnValue(undefined), - requestCredential: vi.fn(), - state: {}, - }; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + // Mock operationParser to return a body parameter + ( + tool as unknown as {operationParser: {getParameters: () => unknown[]}} + ).operationParser.getParameters = () => [ + {name: 'body', originalName: 'body', paramLocation: 'body'}, + ]; await tool.runAsync({ - args: {user_id: '123'}, - toolContext: mockContext as unknown as Context, + args: {body: {foo: 'bar'}}, + toolContext: {} as unknown as Context, }); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://api.example.com/users/123', + expect.anything(), expect.objectContaining({ - method: 'GET', + body: JSON.stringify({foo: 'bar'}), }), ); }); - it('should handle header parameters', async () => { - const opWithHeaderParam: OpenAPIV3.OperationObject = { - operationId: 'testOp', - parameters: [ - { - name: 'X-Custom-Header', - in: 'header', - required: true, - schema: {type: 'string'}, - }, - ], - responses: {'200': {description: 'OK'}}, + it('should replace path parameters', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/users/{id}', + method: 'GET', }; - + const operation: OpenAPIV3.OperationObject = {responses: {}}; const tool = new RestApiTool( - 'test_op', - 'Test Op', - {baseUrl: 'https://api.example.com', path: '/test', method: 'GET'}, - opWithHeaderParam, - undefined, - undefined, - {preservePropertyNames: true}, + 'test_tool', + 'description', + endpoint, + operation, ); - const mockContext = { - getAuthResponse: vi.fn().mockReturnValue(undefined), - requestCredential: vi.fn(), - state: {}, + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + ( + tool as unknown as {operationParser: {getParameters: () => unknown[]}} + ).operationParser.getParameters = () => [ + {name: 'id', originalName: 'id', paramLocation: 'path'}, + ]; + + await tool.runAsync({ + args: {id: '123'}, + toolContext: {} as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://api.example.com/users/123', + expect.anything(), + ); + }); + + it('should stringify bodyData', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'POST', }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; + const tool = new RestApiTool( + 'test_tool', + 'description', + endpoint, + operation, + ); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + ( + tool as unknown as {operationParser: {getParameters: () => unknown[]}} + ).operationParser.getParameters = () => [ + {name: 'user', originalName: 'user', paramLocation: 'body'}, + ]; await tool.runAsync({ - args: {'X-Custom-Header': 'my-value'}, - toolContext: mockContext as unknown as Context, + args: {user: {name: 'Alice'}}, + toolContext: {} as unknown as Context, }); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://api.example.com/test', + expect.anything(), expect.objectContaining({ - headers: expect.objectContaining({ - 'X-Custom-Header': 'my-value', - }), + body: JSON.stringify({user: {name: 'Alice'}}), }), ); }); - it('should handle explicit body parameter', async () => { - const opWithExplicitBody: OpenAPIV3.OperationObject = { - operationId: 'testOp', - requestBody: { - content: { - 'application/json': { - schema: { - type: 'object', - }, - }, - }, - }, - responses: {'200': {description: 'OK'}}, + it('should return pending if auth is pending', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'GET', }; - + const operation: OpenAPIV3.OperationObject = {responses: {}}; const tool = new RestApiTool( - 'test_op', - 'Test Op', - {baseUrl: 'https://api.example.com', path: '/test', method: 'POST'}, - opWithExplicitBody, + 'test_tool', + 'description', + endpoint, + operation, ); - const mockContext = { - getAuthResponse: vi.fn().mockReturnValue(undefined), - requestCredential: vi.fn(), - state: {}, + const mockAuthHandler = { + prepareAuthCredentials: async () => ({state: 'pending'}), }; + vi.spyOn(ToolAuthHandler, 'fromToolContext').mockReturnValue( + mockAuthHandler as unknown as ToolAuthHandler, + ); + + const result = await tool.runAsync({ + args: {}, + toolContext: {} as unknown as Context, + }); + + expect(result).toEqual({ + pending: true, + message: 'Needs your authorization to access your data.', + }); + }); + + it('should add header parameters', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'GET', + }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; + const tool = new RestApiTool( + 'test_tool', + 'description', + endpoint, + operation, + ); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + ( + tool as unknown as {operationParser: {getParameters: () => unknown[]}} + ).operationParser.getParameters = () => [ + {name: 'x-trace-id', originalName: 'X-Trace-Id', paramLocation: 'header'}, + ]; - const bodyObj = {some: 'data'}; await tool.runAsync({ - args: {body: bodyObj}, - toolContext: mockContext as unknown as Context, + args: {'x-trace-id': 'trace-123'}, + toolContext: {} as unknown as Context, }); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://api.example.com/test', + expect.anything(), expect.objectContaining({ - body: JSON.stringify(bodyObj), + headers: expect.objectContaining({'X-Trace-Id': 'trace-123'}), }), ); }); - it('should return declaration', () => { + it('should get declaration', () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'GET', + }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; const tool = new RestApiTool( - 'create_user', - 'Create a user', - {baseUrl: 'https://api.example.com', path: '/users', method: 'POST'}, - mockOperation, + 'test_tool', + 'description', + endpoint, + operation, ); - const declaration = tool._getDeclaration(); - expect(declaration.name).toBe('create_user'); - expect(declaration.description).toBe('Create a user'); - expect(declaration.parameters).toBeTruthy(); + const mockSchema = {type: 'object', properties: {}}; + ( + tool as unknown as {operationParser: {getJsonSchema: () => unknown}} + ).operationParser.getJsonSchema = () => mockSchema; + + const declaration = ( + tool as unknown as {_getDeclaration: () => unknown} + )._getDeclaration(); + + expect(declaration).toEqual({ + name: 'test_tool', + description: 'description', + parameters: mockSchema, + }); }); - it('should return pending state when auth is pending', async () => { + it('should extract query parameters from path', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test?existing=param', + method: 'GET', + }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; const tool = new RestApiTool( - 'test_op', - 'Test Op', - {baseUrl: 'https://api.example.com', path: '/test', method: 'GET'}, - {operationId: 'testOp', responses: {}}, - {type: 'apiKey', in: 'header', name: 'key'}, + 'test_tool', + 'description', + endpoint, + operation, + ); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + ( + tool as unknown as {operationParser: {getParameters: () => unknown[]}} + ).operationParser.getParameters = () => [ + {name: 'new_param', originalName: 'new_param', paramLocation: 'query'}, + ]; + + await tool.runAsync({ + args: {new_param: 'value'}, + toolContext: {} as unknown as Context, + }); + + // Verify URL contains both parameters + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('http://api.example.com/test'), + expect.anything(), ); + const calledUrl = vi.mocked(globalThis.fetch).mock.calls[0][0] as string; + expect(calledUrl).toContain('existing=param'); + expect(calledUrl).toContain('new_param=value'); + }); - const mockContext = { - getAuthResponse: vi.fn().mockReturnValue(undefined), - requestCredential: vi.fn(), - state: {}, + it('should handle application/x-www-form-urlencoded body', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'POST', + }; + const operation: OpenAPIV3.OperationObject = { + responses: {}, + requestBody: { + content: { + 'application/x-www-form-urlencoded': { + schema: {type: 'object'}, + }, + }, + }, }; + const tool = new RestApiTool( + 'test_tool', + 'description', + endpoint, + operation, + ); - const result = await tool.runAsync({ - args: {}, - toolContext: mockContext as unknown as Context, + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', }); - expect(result).toEqual({ - pending: true, - message: 'Needs your authorization to access your data.', + ( + tool as unknown as {operationParser: {getParameters: () => unknown[]}} + ).operationParser.getParameters = () => [ + {name: 'foo', originalName: 'foo', paramLocation: 'body'}, + {name: 'baz', originalName: 'baz', paramLocation: 'body'}, + ]; + + await tool.runAsync({ + args: {foo: 'bar', baz: 'qux'}, + toolContext: {} as unknown as Context, }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: expect.any(URLSearchParams), + }), + ); + const calledBody = vi.mocked(globalThis.fetch).mock.calls[0][1]! + .body as URLSearchParams; + expect(calledBody.get('foo')).toBe('bar'); + expect(calledBody.get('baz')).toBe('qux'); }); - it('should apply headers from headerProvider', async () => { - const headerProvider = vi - .fn() - .mockReturnValue({'X-Dynamic-Header': 'dynamic-value'}); + it('should handle multipart/form-data body', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'POST', + }; + const operation: OpenAPIV3.OperationObject = { + responses: {}, + requestBody: { + content: { + 'multipart/form-data': { + schema: {type: 'object'}, + }, + }, + }, + }; const tool = new RestApiTool( - 'test_op', - 'Test Op', - {baseUrl: 'https://api.example.com', path: '/test', method: 'GET'}, - {operationId: 'testOp', responses: {}}, - undefined, - undefined, - {headerProvider}, + 'test_tool', + 'description', + endpoint, + operation, ); - const mockContext = { - getAuthResponse: vi.fn().mockReturnValue(undefined), - requestCredential: vi.fn(), - state: {}, - }; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + ( + tool as unknown as {operationParser: {getParameters: () => unknown[]}} + ).operationParser.getParameters = () => [ + {name: 'foo', originalName: 'foo', paramLocation: 'body'}, + {name: 'file', originalName: 'file', paramLocation: 'body'}, + ]; await tool.runAsync({ - args: {}, - toolContext: mockContext as unknown as Context, + args: {foo: 'bar', file: 'content'}, + toolContext: {} as unknown as Context, }); - expect(headerProvider).toHaveBeenCalled(); expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://api.example.com/test', + expect.anything(), expect.objectContaining({ - headers: expect.objectContaining({ - 'X-Dynamic-Header': 'dynamic-value', - }), + body: expect.any(FormData), }), ); + const calledBody = vi.mocked(globalThis.fetch).mock.calls[0][1]! + .body as FormData; + expect(calledBody.get('foo')).toBe('bar'); + expect(calledBody.get('file')).toBe('content'); + }); + + it('should handle fetch error', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'GET', + }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; + const tool = new RestApiTool( + 'test_tool', + 'description', + endpoint, + operation, + ); + + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const result = await tool.runAsync({ + args: {}, + toolContext: {} as unknown as Context, + }); + + expect(result).toEqual({ + error: 'Failed to execute API call: Network error', + }); }); }); From 751da952d5a3ddf23b78cd15c29af74822462a1e Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 23 Apr 2026 12:48:50 -0700 Subject: [PATCH 5/8] test(openapi): add auth test and coverage improvements --- core/src/tools/openapi_tool/rest_api_tool.ts | 3 +- .../tools/openapi_tool/rest_api_tool_test.ts | 89 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/core/src/tools/openapi_tool/rest_api_tool.ts b/core/src/tools/openapi_tool/rest_api_tool.ts index a43992bf..3a7bad20 100644 --- a/core/src/tools/openapi_tool/rest_api_tool.ts +++ b/core/src/tools/openapi_tool/rest_api_tool.ts @@ -108,7 +108,8 @@ export class RestApiTool extends BaseTool { const authHandler = ToolAuthHandler.fromToolContext( context, this.authScheme, - undefined, // We rely on context to provide credential + this.authCredential, + {credentialKey: this.credentialKey}, ); const authResult = await authHandler.prepareAuthCredentials(); 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 762f2d52..ff3853c2 100644 --- a/core/test/tools/openapi_tool/rest_api_tool_test.ts +++ b/core/test/tools/openapi_tool/rest_api_tool_test.ts @@ -460,4 +460,93 @@ describe('RestApiTool', () => { error: 'Failed to execute API call: Network error', }); }); + + it('should apply auth credentials to fetch request', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'GET', + }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; + const authScheme: OpenAPIV3.SecuritySchemeObject = { + type: 'apiKey', + name: 'X-API-Key', + in: 'header', + }; + const tool = new RestApiTool( + 'test_tool', + 'description', + endpoint, + operation, + authScheme, + ); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + const mockAuthHandler = { + prepareAuthCredentials: async () => ({ + state: 'done', + authCredential: {apiKey: 'secret_key'}, + }), + }; + vi.spyOn(ToolAuthHandler, 'fromToolContext').mockReturnValue( + mockAuthHandler as unknown as ToolAuthHandler, + ); + + await tool.runAsync({ + args: {}, + toolContext: {} as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({'X-API-Key': 'secret_key'}), + }), + ); + }); + + it('should fallback to JSON if no requestBody in spec', async () => { + const endpoint = { + baseUrl: 'http://api.example.com', + path: '/test', + method: 'POST', + }; + const operation: OpenAPIV3.OperationObject = {responses: {}}; + const tool = new RestApiTool( + 'test_tool', + 'description', + endpoint, + operation, + ); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => 'text/plain'}, + text: async () => 'ok', + }); + + ( + tool as unknown as {operationParser: {getParameters: () => unknown[]}} + ).operationParser.getParameters = () => [ + {name: 'body', originalName: 'body', paramLocation: 'body'}, + ]; + + await tool.runAsync({ + args: {body: {foo: 'bar'}}, + toolContext: {} as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({'Content-Type': 'application/json'}), + body: JSON.stringify({foo: 'bar'}), + }), + ); + }); }); From 4bb293ea82902de71c6f238b64de5c69388d1c4c Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 23 Apr 2026 13:05:50 -0700 Subject: [PATCH 6/8] feat(openapi): achieve parity with Python and improve test coverage --- .../openapi_spec_parser.ts | 80 ++++++++++++++++- .../openapi_spec_parser/tool_auth_handler.ts | 49 +++++++++-- .../openapi_tool/openapi_toolset_test.ts | 87 +++++++++++++++++++ .../openapi_tool/tool_auth_handler_test.ts | 49 +++++++++-- 4 files changed, 251 insertions(+), 14 deletions(-) diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts index 4e7ed901..6cf6f9c9 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts @@ -8,6 +8,16 @@ import {OpenAPIV3} from 'openapi-types'; import {experimental} from '../../../utils/experimental.js'; import {ApiParameter, OperationParser} from './operation_parser.js'; +const VALID_SCHEMA_TYPES = new Set([ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', +]); + export interface OperationEndpoint { baseUrl: string; path: string; @@ -35,8 +45,8 @@ export class OpenApiSpecParser { @experimental public parse(openapiSpec: OpenAPIV3.Document): ParsedOperation[] { const resolvedSpec = this.resolveReferences(openapiSpec); - // Skipping sanitizeSchemaTypes for now unless we find it's needed for Gemini - return this.collectOperations(resolvedSpec); + const sanitizedSpec = this.sanitizeSchemaTypes(resolvedSpec); + return this.collectOperations(sanitizedSpec); } private resolveReferences(spec: OpenAPIV3.Document): OpenAPIV3.Document { @@ -117,6 +127,72 @@ export class OpenApiSpecParser { return recursiveResolve(specCopy, specCopy) as OpenAPIV3.Document; } + private sanitizeSchemaTypes( + openapiSpec: OpenAPIV3.Document, + ): OpenAPIV3.Document { + const specCopy = JSON.parse(JSON.stringify(openapiSpec)); + + const sanitizeTypeField = (schemaDict: Record) => { + if (!('type' in schemaDict)) return; + + const typeValue = schemaDict['type']; + if (typeof typeValue === 'string') { + const normalizedType = typeValue.toLowerCase(); + if (VALID_SCHEMA_TYPES.has(normalizedType)) { + schemaDict['type'] = normalizedType; + } else { + delete schemaDict['type']; + } + return; + } + + if (Array.isArray(typeValue)) { + const validTypes: string[] = []; + for (const entry of typeValue) { + if (typeof entry !== 'string') continue; + const normalizedEntry = entry.toLowerCase(); + if ( + VALID_SCHEMA_TYPES.has(normalizedEntry) && + !validTypes.includes(normalizedEntry) + ) { + validTypes.push(normalizedEntry); + } + } + if (validTypes.length > 0) { + schemaDict['type'] = validTypes; + } else { + delete schemaDict['type']; + } + } + }; + + const sanitizeRecursive = (obj: unknown, inSchema: boolean): unknown => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => sanitizeRecursive(item, inSchema)); + } + + const objRecord = obj as Record; + if (inSchema) { + sanitizeTypeField(objRecord); + } + + for (const [key, value] of Object.entries(objRecord)) { + const isSchemaContainer = key === 'schema' || key === 'schemas'; + objRecord[key] = sanitizeRecursive( + value, + inSchema || isSchemaContainer, + ); + } + return objRecord; + }; + + return sanitizeRecursive(specCopy, false) as OpenAPIV3.Document; + } + private collectOperations(spec: OpenAPIV3.Document): ParsedOperation[] { const operations: ParsedOperation[] = []; const baseUrl = spec.servers?.[0]?.url || ''; diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts index 377b033c..27e9f30c 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts @@ -1,9 +1,3 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - import {OpenAPIV3} from 'openapi-types'; import {Context} from '../../../agents/context.js'; import {AuthCredential} from '../../../auth/auth_credential.js'; @@ -16,6 +10,38 @@ export interface AuthPreparationResult { authCredential?: AuthCredential; } +class ToolContextCredentialStore { + constructor(private readonly context: Context) {} + + getCredentialKey(authScheme?: OpenAPIV3.SecuritySchemeObject): string { + const schemeName = authScheme?.type || 'default'; + return `${schemeName}_existing_exchanged_credential`; + } + + getCredential( + authScheme?: OpenAPIV3.SecuritySchemeObject, + ): AuthCredential | undefined { + const key = this.getCredentialKey(authScheme); + const state = (this.context as unknown as {state: Record}) + .state; + if (state) { + const serialized = state[key]; + if (serialized) { + return serialized as AuthCredential; + } + } + return undefined; + } + + storeCredential(key: string, credential: AuthCredential) { + const state = (this.context as unknown as {state: Record}) + .state; + if (state) { + state[key] = credential; + } + } +} + @experimental export class ToolAuthHandler { constructor( @@ -46,6 +72,13 @@ export class ToolAuthHandler { return {state: 'done'}; } + const store = new ToolContextCredentialStore(this.context); + const existingCredential = store.getCredential(this.authScheme); + + if (existingCredential) { + return {state: 'done', authCredential: existingCredential}; + } + const authConfig: AuthConfig = { authScheme: this.authScheme, rawAuthCredential: this.authCredential, @@ -59,6 +92,10 @@ export class ToolAuthHandler { authScheme: this.authScheme, authCredential: credential, }); + + const key = store.getCredentialKey(this.authScheme); + store.storeCredential(key, result.credential); + return {state: 'done', authCredential: result.credential}; } diff --git a/core/test/tools/openapi_tool/openapi_toolset_test.ts b/core/test/tools/openapi_tool/openapi_toolset_test.ts index 231e70f0..c4e81008 100644 --- a/core/test/tools/openapi_tool/openapi_toolset_test.ts +++ b/core/test/tools/openapi_tool/openapi_toolset_test.ts @@ -318,4 +318,91 @@ describe('OpenApiSpecParser', () => { scheme: 'bearer', }); }); + + it('should sanitize invalid schema types', () => { + const specWithInvalidType = { + openapi: '3.0.0', + info: {title: 'Test', version: '1.0'}, + paths: { + '/test': { + get: { + operationId: 'testOp', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + invalidProp: {type: 'Any'}, + validProp: {type: 'string'}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } as unknown as OpenAPIV3.Document; + + const parser = new OpenApiSpecParser(); + const operations = parser.parse(specWithInvalidType); + + expect(operations.length).toBe(1); + const schema = operations[0].operation.responses?.['200']?.content?.[ + 'application/json' + ]?.schema as OpenAPIV3.SchemaObject; + const invalidPropSchema = schema.properties?.[ + 'invalidProp' + ] as OpenAPIV3.SchemaObject; + const validPropSchema = schema.properties?.[ + 'validProp' + ] as OpenAPIV3.SchemaObject; + expect(invalidPropSchema.type).toBeUndefined(); + expect(validPropSchema.type).toBe('string'); + }); + + it('should sanitize invalid schema types in array', () => { + const specWithInvalidArrayType = { + openapi: '3.0.0', + info: {title: 'Test', version: '1.0'}, + paths: { + '/test': { + get: { + operationId: 'testOp', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + multiProp: {type: ['string', 'Any', 'integer']}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } as unknown as OpenAPIV3.Document; + + const parser = new OpenApiSpecParser(); + const operations = parser.parse(specWithInvalidArrayType); + + expect(operations.length).toBe(1); + const schema = operations[0].operation.responses?.['200']?.content?.[ + 'application/json' + ]?.schema as OpenAPIV3.SchemaObject; + const multiPropSchema = schema.properties?.[ + 'multiProp' + ] as OpenAPIV3.SchemaObject; + expect(multiPropSchema.type).toEqual(['string', 'integer']); + }); }); diff --git a/core/test/tools/openapi_tool/tool_auth_handler_test.ts b/core/test/tools/openapi_tool/tool_auth_handler_test.ts index a8badd6e..8c606ca2 100644 --- a/core/test/tools/openapi_tool/tool_auth_handler_test.ts +++ b/core/test/tools/openapi_tool/tool_auth_handler_test.ts @@ -40,12 +40,10 @@ describe('ToolAuthHandler', () => { it('should return done after exchange if credential in context', async () => { const mockContext = { - getAuthResponse: vi - .fn() - .mockReturnValue({ - authType: AuthCredentialTypes.API_KEY, - apiKey: 'key', - }), + getAuthResponse: vi.fn().mockReturnValue({ + authType: AuthCredentialTypes.API_KEY, + apiKey: 'key', + }), } as unknown as Context; const handler = new ToolAuthHandler(mockContext, {type: 'apiKey'}); @@ -71,4 +69,43 @@ describe('ToolAuthHandler', () => { expect(result.state).toBe('pending'); expect(mockContext.requestCredential).toHaveBeenCalled(); }); + + it('should return cached credential if available', async () => { + const mockContext = { + state: { + 'apiKey_existing_exchanged_credential': { + authType: AuthCredentialTypes.HTTP, + http: {scheme: 'bearer', credentials: {token: 'cached-token'}}, + }, + }, + } as unknown as Context; + + const handler = new ToolAuthHandler(mockContext, {type: 'apiKey'}); + + const result = await handler.prepareAuthCredentials(); + + expect(result.state).toBe('done'); + expect(result.authCredential?.http?.credentials.token).toBe('cached-token'); + }); + + it('should store exchanged credential in state', async () => { + const mockContext = { + state: {}, + getAuthResponse: vi.fn().mockReturnValue({ + authType: AuthCredentialTypes.API_KEY, + apiKey: 'key', + }), + } as unknown as Context; + + const handler = new ToolAuthHandler(mockContext, {type: 'apiKey'}); + + const result = await handler.prepareAuthCredentials(); + + expect(result.state).toBe('done'); + expect( + (mockContext as unknown as {state: Record}).state[ + 'apiKey_existing_exchanged_credential' + ], + ).toBeTruthy(); + }); }); From cd2587c92dceaa32ee31e4f00a034a15656c33c9 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Thu, 23 Apr 2026 13:24:20 -0700 Subject: [PATCH 7/8] chore: add missing license headers --- .../openapi_tool/openapi_spec_parser/tool_auth_handler.ts | 6 ++++++ core/test/tools/openapi_tool/auth_helpers_test.ts | 6 ++++++ core/test/tools/openapi_tool/rest_api_tool_test.ts | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts index 27e9f30c..2f41a7fb 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {OpenAPIV3} from 'openapi-types'; import {Context} from '../../../agents/context.js'; import {AuthCredential} from '../../../auth/auth_credential.js'; diff --git a/core/test/tools/openapi_tool/auth_helpers_test.ts b/core/test/tools/openapi_tool/auth_helpers_test.ts index 9e79895d..aecba6bd 100644 --- a/core/test/tools/openapi_tool/auth_helpers_test.ts +++ b/core/test/tools/openapi_tool/auth_helpers_test.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {OpenAPIV3} from 'openapi-types'; import {describe, expect, it} from 'vitest'; import {AuthCredential} from '../../../src/auth/auth_credential.js'; 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 ff3853c2..07524a8a 100644 --- a/core/test/tools/openapi_tool/rest_api_tool_test.ts +++ b/core/test/tools/openapi_tool/rest_api_tool_test.ts @@ -1,3 +1,9 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + import {OpenAPIV3} from 'openapi-types'; import {afterEach, describe, expect, it, vi} from 'vitest'; import {Context} from '../../../src/agents/context.js'; From 2e55af366b24f7001e7262bb6fa9cb0da8101b88 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Mon, 27 Apr 2026 11:20:07 -0700 Subject: [PATCH 8/8] refactor(openapi): move non-this methods out of OpenApiSpecParser class --- .../openapi_spec_parser.ts | 285 +++++++++--------- 1 file changed, 139 insertions(+), 146 deletions(-) diff --git a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts index 6cf6f9c9..089ed043 100644 --- a/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts +++ b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts @@ -44,155 +44,11 @@ export class OpenApiSpecParser { @experimental public parse(openapiSpec: OpenAPIV3.Document): ParsedOperation[] { - const resolvedSpec = this.resolveReferences(openapiSpec); - const sanitizedSpec = this.sanitizeSchemaTypes(resolvedSpec); + const resolvedSpec = resolveReferences(openapiSpec); + const sanitizedSpec = sanitizeSchemaTypes(resolvedSpec); return this.collectOperations(sanitizedSpec); } - private resolveReferences(spec: OpenAPIV3.Document): OpenAPIV3.Document { - const resolvedCache = new Map(); - const specCopy = JSON.parse(JSON.stringify(spec)); // Deep copy - - const resolveRef = ( - refString: string, - currentDoc: OpenAPIV3.Document, - ): unknown => { - const parts = refString.split('/'); - if (parts[0] !== '#') { - throw new Error(`External references not supported: ${refString}`); - } - - let current: unknown = currentDoc; - for (const part of parts.slice(1)) { - if ( - typeof current === 'object' && - current !== null && - part in current - ) { - current = (current as Record)[part]; - } else { - return undefined; - } - } - return current; - }; - - const recursiveResolve = ( - obj: unknown, - currentDoc: OpenAPIV3.Document, - seenRefs = new Set(), - ): unknown => { - if (typeof obj !== 'object' || obj === null) { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map((item) => recursiveResolve(item, currentDoc, seenRefs)); - } - - const objRecord = obj as Record; - if ('$ref' in objRecord && typeof objRecord['$ref'] === 'string') { - const refString = objRecord['$ref'] as string; - - if (seenRefs.has(refString) && !resolvedCache.has(refString)) { - // Circular reference detected. Break cycle. - const copy = {...objRecord}; - delete copy['$ref']; - return copy; - } - - seenRefs.add(refString); - - if (resolvedCache.has(refString)) { - return resolvedCache.get(refString); - } - - let resolvedValue = resolveRef(refString, currentDoc); - if (resolvedValue !== undefined) { - resolvedValue = recursiveResolve(resolvedValue, currentDoc, seenRefs); - resolvedCache.set(refString, resolvedValue); - return resolvedValue; - } else { - return obj; - } - } - - const newDict: Record = {}; - for (const [key, value] of Object.entries(objRecord)) { - newDict[key] = recursiveResolve(value, currentDoc, seenRefs); - } - return newDict; - }; - - return recursiveResolve(specCopy, specCopy) as OpenAPIV3.Document; - } - - private sanitizeSchemaTypes( - openapiSpec: OpenAPIV3.Document, - ): OpenAPIV3.Document { - const specCopy = JSON.parse(JSON.stringify(openapiSpec)); - - const sanitizeTypeField = (schemaDict: Record) => { - if (!('type' in schemaDict)) return; - - const typeValue = schemaDict['type']; - if (typeof typeValue === 'string') { - const normalizedType = typeValue.toLowerCase(); - if (VALID_SCHEMA_TYPES.has(normalizedType)) { - schemaDict['type'] = normalizedType; - } else { - delete schemaDict['type']; - } - return; - } - - if (Array.isArray(typeValue)) { - const validTypes: string[] = []; - for (const entry of typeValue) { - if (typeof entry !== 'string') continue; - const normalizedEntry = entry.toLowerCase(); - if ( - VALID_SCHEMA_TYPES.has(normalizedEntry) && - !validTypes.includes(normalizedEntry) - ) { - validTypes.push(normalizedEntry); - } - } - if (validTypes.length > 0) { - schemaDict['type'] = validTypes; - } else { - delete schemaDict['type']; - } - } - }; - - const sanitizeRecursive = (obj: unknown, inSchema: boolean): unknown => { - if (typeof obj !== 'object' || obj === null) { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map((item) => sanitizeRecursive(item, inSchema)); - } - - const objRecord = obj as Record; - if (inSchema) { - sanitizeTypeField(objRecord); - } - - for (const [key, value] of Object.entries(objRecord)) { - const isSchemaContainer = key === 'schema' || key === 'schemas'; - objRecord[key] = sanitizeRecursive( - value, - inSchema || isSchemaContainer, - ); - } - return objRecord; - }; - - return sanitizeRecursive(specCopy, false) as OpenAPIV3.Document; - } - private collectOperations(spec: OpenAPIV3.Document): ParsedOperation[] { const operations: ParsedOperation[] = []; const baseUrl = spec.servers?.[0]?.url || ''; @@ -265,3 +121,140 @@ export class OpenApiSpecParser { return operations; } } + +function resolveReferences(spec: OpenAPIV3.Document): OpenAPIV3.Document { + const resolvedCache = new Map(); + const specCopy = JSON.parse(JSON.stringify(spec)); // Deep copy + + const resolveRef = ( + refString: string, + currentDoc: OpenAPIV3.Document, + ): unknown => { + const parts = refString.split('/'); + if (parts[0] !== '#') { + throw new Error(`External references not supported: ${refString}`); + } + + let current: unknown = currentDoc; + for (const part of parts.slice(1)) { + if (typeof current === 'object' && current !== null && part in current) { + current = (current as Record)[part]; + } else { + return undefined; + } + } + return current; + }; + + const recursiveResolve = ( + obj: unknown, + currentDoc: OpenAPIV3.Document, + seenRefs = new Set(), + ): unknown => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => recursiveResolve(item, currentDoc, seenRefs)); + } + + const objRecord = obj as Record; + if ('$ref' in objRecord && typeof objRecord['$ref'] === 'string') { + const refString = objRecord['$ref'] as string; + + if (seenRefs.has(refString) && !resolvedCache.has(refString)) { + // Circular reference detected. Break cycle. + const copy = {...objRecord}; + delete copy['$ref']; + return copy; + } + + seenRefs.add(refString); + + if (resolvedCache.has(refString)) { + return resolvedCache.get(refString); + } + + let resolvedValue = resolveRef(refString, currentDoc); + if (resolvedValue !== undefined) { + resolvedValue = recursiveResolve(resolvedValue, currentDoc, seenRefs); + resolvedCache.set(refString, resolvedValue); + return resolvedValue; + } else { + return obj; + } + } + + const newDict: Record = {}; + for (const [key, value] of Object.entries(objRecord)) { + newDict[key] = recursiveResolve(value, currentDoc, seenRefs); + } + return newDict; + }; + + return recursiveResolve(specCopy, specCopy) as OpenAPIV3.Document; +} + +function sanitizeSchemaTypes( + openapiSpec: OpenAPIV3.Document, +): OpenAPIV3.Document { + const specCopy = JSON.parse(JSON.stringify(openapiSpec)); + + const sanitizeTypeField = (schemaDict: Record) => { + if (!('type' in schemaDict)) return; + + const typeValue = schemaDict['type']; + if (typeof typeValue === 'string') { + const normalizedType = typeValue.toLowerCase(); + if (VALID_SCHEMA_TYPES.has(normalizedType)) { + schemaDict['type'] = normalizedType; + } else { + delete schemaDict['type']; + } + return; + } + + if (Array.isArray(typeValue)) { + const validTypes: string[] = []; + for (const entry of typeValue) { + if (typeof entry !== 'string') continue; + const normalizedEntry = entry.toLowerCase(); + if ( + VALID_SCHEMA_TYPES.has(normalizedEntry) && + !validTypes.includes(normalizedEntry) + ) { + validTypes.push(normalizedEntry); + } + } + if (validTypes.length > 0) { + schemaDict['type'] = validTypes; + } else { + delete schemaDict['type']; + } + } + }; + + const sanitizeRecursive = (obj: unknown, inSchema: boolean): unknown => { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => sanitizeRecursive(item, inSchema)); + } + + const objRecord = obj as Record; + if (inSchema) { + sanitizeTypeField(objRecord); + } + + for (const [key, value] of Object.entries(objRecord)) { + const isSchemaContainer = key === 'schema' || key === 'schemas'; + objRecord[key] = sanitizeRecursive(value, inSchema || isSchemaContainer); + } + return objRecord; + }; + + return sanitizeRecursive(specCopy, false) as OpenAPIV3.Document; +}