From 0f02ab1ac2a5f3e81f265420f6456b1f0cb47af8 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Wed, 27 May 2026 13:27:10 -0700 Subject: [PATCH] feat(openapi): implement openapi toolset and integration tests (part 4) This implements OpenAPIToolset and adds integration tests and fixtures. TAG=agy CONV=5d70d8ac-425c-4f8c-a969-97b17ecc66ce --- .../src/tools/openapi_tool/openapi_toolset.ts | 110 +++ .../tools/openapi_tool/fixtures/petstore.yaml | 841 ++++++++++++++++++ .../tools/openapi_tool/fixtures/truanon.yaml | 86 ++ .../openapi_toolset_integration_test.ts | 127 +++ .../openapi_tool/openapi_toolset_test.ts | 408 +++++++++ 5 files changed, 1572 insertions(+) create mode 100644 core/src/tools/openapi_tool/openapi_toolset.ts create mode 100644 core/test/tools/openapi_tool/fixtures/petstore.yaml create mode 100644 core/test/tools/openapi_tool/fixtures/truanon.yaml create mode 100644 core/test/tools/openapi_tool/openapi_toolset_integration_test.ts create mode 100644 core/test/tools/openapi_tool/openapi_toolset_test.ts 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/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']); + }); +});