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..b000a545 --- /dev/null +++ b/core/src/tools/openapi_tool/auth/auth_helpers.ts @@ -0,0 +1,79 @@ +/** + * @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.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.apiKey; + } else if (inLocation === 'query') { + const separator = url.includes('?') ? '&' : '?'; + url += `${separator}${name}=${encodeURIComponent(credential.apiKey)}`; + } else { + // Default to header Authorization if not specified or unknown location + headers['Authorization'] = credential.apiKey; + } + } 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..089ed043 --- /dev/null +++ b/core/src/tools/openapi_tool/openapi_spec_parser/openapi_spec_parser.ts @@ -0,0 +1,260 @@ +/** + * @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'; + +const VALID_SCHEMA_TYPES = new Set([ + 'array', + 'boolean', + 'integer', + 'null', + 'number', + 'object', + 'string', +]); + +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 = resolveReferences(openapiSpec); + const sanitizedSpec = sanitizeSchemaTypes(resolvedSpec); + return this.collectOperations(sanitizedSpec); + } + + 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; + } +} + +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; +} 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..2f41a7fb --- /dev/null +++ b/core/src/tools/openapi_tool/openapi_spec_parser/tool_auth_handler.ts @@ -0,0 +1,113 @@ +/** + * @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; +} + +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( + 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 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, + 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, + }); + + const key = store.getCredentialKey(this.authScheme); + store.storeCredential(key, result.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..3a7bad20 --- /dev/null +++ b/core/src/tools/openapi_tool/rest_api_tool.ts @@ -0,0 +1,261 @@ +/** + * @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, + parameters: schema, + }; + } + + @experimental + override async runAsync(request: RunAsyncToolRequest): Promise { + const context = request.toolContext as Context; + const args = request.args; + + const authHandler = ToolAuthHandler.fromToolContext( + context, + this.authScheme, + this.authCredential, + {credentialKey: this.credentialKey}, + ); + + 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 = {}; + + 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); + } + + // 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) { + url += `?${queryString}`; + } + + // Handle 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 + 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, + // eslint-disable-next-line no-undef + body: body as BodyInit, + }); + + 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..aecba6bd --- /dev/null +++ b/core/test/tools/openapi_tool/auth_helpers_test.ts @@ -0,0 +1,122 @@ +/** + * @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, + createBearerScheme, +} from '../../../src/tools/openapi_tool/auth/auth_helpers.js'; + +describe('auth_helpers', () => { + describe('applyCredential', () => { + 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); + expect(headers).toEqual({}); + }); + + it('should apply API key in header', () => { + const url = 'http://example.com'; + const headers: Record = {}; + 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('secret_key'); + }); + + it('should apply API key in query', () => { + const url = 'http://example.com'; + const headers: Record = {}; + 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('http://example.com?api_key=secret_key'); + expect(headers).toEqual({}); + }); + + it('should apply API key in query with existing params', () => { + const url = 'http://example.com?foo=bar'; + const headers: Record = {}; + 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('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 = 'http://example.com'; + const headers: Record = {}; + const credential: AuthCredential = { + http: { + credentials: { + token: 'my_token', + }, + }, + }; + + const result = applyCredential(url, headers, credential); + + expect(result).toBe(url); + expect(headers['Authorization']).toBe('Bearer my_token'); + }); + }); + + 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', + }); + }); + }); + + 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/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..c4e81008 --- /dev/null +++ b/core/test/tools/openapi_tool/openapi_toolset_test.ts @@ -0,0 +1,408 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; + +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'}); + }); + + 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', () => { + 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', + }); + }); + + 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/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..07524a8a --- /dev/null +++ b/core/test/tools/openapi_tool/rest_api_tool_test.ts @@ -0,0 +1,558 @@ +/** + * @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'; +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', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + 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( + 'test_tool', + 'description', + endpoint, + operation, + ); + + tool.configureCredentialKey('my-credential-key'); + + expect((tool as unknown as {credentialKey: string}).credentialKey).toBe( + 'my-credential-key', + ); + }); + + 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}, + ); + + 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(headerProvider).toHaveBeenCalledWith(mockContext); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({'X-Custom-Header': 'custom-value'}), + }), + ); + }); + + 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( + 'test_tool', + 'description', + endpoint, + operation, + ); + + 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: {body: {foo: 'bar'}}, + toolContext: {} as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: JSON.stringify({foo: 'bar'}), + }), + ); + }); + + 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_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: '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: {user: {name: 'Alice'}}, + toolContext: {} as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: JSON.stringify({user: {name: 'Alice'}}), + }), + ); + }); + + 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_tool', + 'description', + endpoint, + operation, + ); + + 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'}, + ]; + + await tool.runAsync({ + args: {'x-trace-id': 'trace-123'}, + toolContext: {} as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({'X-Trace-Id': 'trace-123'}), + }), + ); + }); + + it('should get declaration', () => { + 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, + ); + + 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 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_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'); + }); + + 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, + ); + + 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: '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 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_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: 'foo', originalName: 'foo', paramLocation: 'body'}, + {name: 'file', originalName: 'file', paramLocation: 'body'}, + ]; + + await tool.runAsync({ + args: {foo: 'bar', file: 'content'}, + toolContext: {} as unknown as Context, + }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + 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', + }); + }); + + 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'}), + }), + ); + }); +}); 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..8c606ca2 --- /dev/null +++ b/core/test/tools/openapi_tool/tool_auth_handler_test.ts @@ -0,0 +1,111 @@ +/** + * @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(); + }); + + 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(); + }); +});