From 1e114cd22042fa3b3de252d45a7c291da641d443 Mon Sep 17 00:00:00 2001 From: Google Team Member Date: Wed, 16 Jul 2025 09:26:53 -0700 Subject: [PATCH] feat: Introduced ConnectionClient and IntegrationClient to get OPENAPISPEC of connection PiperOrigin-RevId: 783786672 --- .../ApplicationIntegrationToolset.java | 203 +++-- .../ConnectionsClient.java | 860 ++++++++++++++++++ .../IntegrationClient.java | 331 +++++++ ...ool.java => IntegrationConnectorTool.java} | 183 +++- .../ApplicationIntegrationToolTest.java | 167 ---- .../ApplicationIntegrationToolsetTest.java | 152 ++-- .../ConnectionsClientTest.java | 395 ++++++++ .../IntegrationClientTest.java | 448 +++++++++ .../IntegrationConnectorToolTest.java | 292 ++++++ 9 files changed, 2694 insertions(+), 337 deletions(-) create mode 100644 core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ConnectionsClient.java create mode 100644 core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationClient.java rename core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/{ApplicationIntegrationTool.java => IntegrationConnectorTool.java} (56%) delete mode 100644 core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolTest.java create mode 100644 core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ConnectionsClientTest.java create mode 100644 core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationClientTest.java create mode 100644 core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationConnectorToolTest.java diff --git a/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolset.java b/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolset.java index 7f5602c8e..ece6346f6 100644 --- a/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolset.java +++ b/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolset.java @@ -1,19 +1,14 @@ package com.google.adk.tools.applicationintegrationtoolset; +import static com.google.common.base.Strings.isNullOrEmpty; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.adk.tools.BaseTool; -import com.google.adk.tools.applicationintegrationtoolset.ApplicationIntegrationTool.DefaultHttpExecutor; -import com.google.adk.tools.applicationintegrationtoolset.ApplicationIntegrationTool.HttpExecutor; -import com.google.auth.oauth2.GoogleCredentials; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import com.google.adk.tools.applicationintegrationtoolset.IntegrationConnectorTool.DefaultHttpExecutor; +import com.google.adk.tools.applicationintegrationtoolset.IntegrationConnectorTool.HttpExecutor; import java.util.ArrayList; -import java.util.Arrays; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -23,10 +18,15 @@ public class ApplicationIntegrationToolset { String project; String location; - String integration; - List triggers; - private final HttpExecutor httpExecutor; + @Nullable String integration; + @Nullable List triggers; + @Nullable String connection; + @Nullable Map> entityOperations; + @Nullable List actions; + @Nullable String toolNamePrefix; + @Nullable String toolInstructions; public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + HttpExecutor httpExecutor; /** * ApplicationIntegrationToolset generates tools from a given Application Integration resource. @@ -35,16 +35,48 @@ public class ApplicationIntegrationToolset { * *

integrationTool = new ApplicationIntegrationToolset( project="test-project", * location="us-central1", integration="test-integration", - * triggers=ImmutableList.of("api_trigger/test_trigger", "api_trigger/test_trigger_2")); + * triggers=ImmutableList.of("api_trigger/test_trigger", + * "api_trigger/test_trigger_2"),connection=null,enitityOperations=null,actions=null,toolNamePrefix="test-integration-tool",toolInstructions="This + * tool is used to get response from test-integration."); + * + *

connectionTool = new ApplicationIntegrationToolset( project="test-project", + * location="us-central1", integration=null, triggers=null, connection="test-connection", + * entityOperations=ImmutableMap.of("Entity1", ImmutableList.of("LIST", "GET", "UPDATE")), + * "Entity2", ImmutableList.of()), actions=ImmutableList.of("ExecuteCustomQuery"), + * toolNamePrefix="test-tool", toolInstructions="This tool is used to list, get and update issues + * in Jira."); * * @param project The GCP project ID. * @param location The GCP location of integration. * @param integration The integration name. * @param triggers(Optional) The list of trigger ids in the integration. + * @param connection(Optional) The connection name. + * @param entityOperations(Optional) The entity operations. + * @param actions(Optional) The actions. + * @param toolNamePrefix(Optional) The tool name prefix. + * @param toolInstructions(Optional) The tool instructions. */ public ApplicationIntegrationToolset( - String project, String location, String integration, List triggers) { - this(project, location, integration, triggers, new DefaultHttpExecutor()); + String project, + String location, + String integration, + List triggers, + String connection, + Map> entityOperations, + List actions, + String toolNamePrefix, + String toolInstructions) { + this( + project, + location, + integration, + triggers, + connection, + entityOperations, + actions, + toolNamePrefix, + toolInstructions, + new DefaultHttpExecutor()); } ApplicationIntegrationToolset( @@ -52,63 +84,31 @@ public ApplicationIntegrationToolset( String location, String integration, List triggers, + String connection, + Map> entityOperations, + List actions, + String toolNamePrefix, + String toolInstructions, HttpExecutor httpExecutor) { this.project = project; this.location = location; this.integration = integration; this.triggers = triggers; + this.connection = connection; + this.entityOperations = entityOperations; + this.actions = actions; + this.toolNamePrefix = toolNamePrefix; + this.toolInstructions = toolInstructions; this.httpExecutor = httpExecutor; } - String generateOpenApiSpec() throws Exception { - String url = - String.format( - "https://%s-integrations.googleapis.com/v1/projects/%s/locations/%s:generateOpenApiSpec", - this.location, this.project, this.location); - - String jsonRequestBody = - OBJECT_MAPPER.writeValueAsString( - ImmutableMap.of( - "apiTriggerResources", - ImmutableList.of( - ImmutableMap.of( - "integrationResource", - this.integration, - "triggerId", - Arrays.asList(this.triggers))), - "fileFormat", - "JSON")); - HttpRequest request = - HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Authorization", "Bearer " + getAccessToken()) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(jsonRequestBody)) - .build(); - HttpResponse response = - httpExecutor.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() < 200 || response.statusCode() >= 300) { - throw new Exception("Error fetching OpenAPI spec. Status: " + response.statusCode()); - } - return response.body(); - } - - String getAccessToken() throws IOException { - GoogleCredentials credentials = - GoogleCredentials.getApplicationDefault() - .createScoped(ImmutableList.of("https://www.googleapis.com/auth/cloud-platform")); - credentials.refreshIfExpired(); - return credentials.getAccessToken().getTokenValue(); - } - List getPathUrl(String openApiSchemaString) throws Exception { List pathUrls = new ArrayList<>(); JsonNode topLevelNode = OBJECT_MAPPER.readTree(openApiSchemaString); JsonNode specNode = topLevelNode.path("openApiSpec"); if (specNode.isMissingNode() || !specNode.isTextual()) { throw new IllegalArgumentException( - "API response must contain an 'openApiSpec' key with a string value."); + "Failed to get OpenApiSpec, please check the project and region for the integration."); } JsonNode rootNode = OBJECT_MAPPER.readTree(specNode.asText()); JsonNode pathsNode = rootNode.path("paths"); @@ -121,29 +121,74 @@ List getPathUrl(String openApiSchemaString) throws Exception { return pathUrls; } - @Nullable String extractTriggerIdFromPath(String path) { - String prefix = "triggerId=api_trigger/"; - int startIndex = path.indexOf(prefix); - if (startIndex == -1) { - return null; - } - return path.substring(startIndex + prefix.length()); - } - public List getTools() throws Exception { - String openApiSchemaString = generateOpenApiSpec(); - List pathUrls = getPathUrl(openApiSchemaString); - + String openApiSchemaString = null; List tools = new ArrayList<>(); - for (String pathUrl : pathUrls) { - String toolName = extractTriggerIdFromPath(pathUrl); - if (toolName != null) { - tools.add(new ApplicationIntegrationTool(openApiSchemaString, pathUrl, toolName, "")); - } else { - System.err.println( - "Failed to get tool name , Please check the integration name , trigger id and location" - + " and project id."); + if (!isNullOrEmpty(this.integration)) { + IntegrationClient integrationClient = + new IntegrationClient( + this.project, + this.location, + this.integration, + this.triggers, + null, + null, + null, + this.httpExecutor); + openApiSchemaString = integrationClient.generateOpenApiSpec(); + List pathUrls = getPathUrl(openApiSchemaString); + for (String pathUrl : pathUrls) { + String toolName = integrationClient.getOperationIdFromPathUrl(openApiSchemaString, pathUrl); + if (toolName != null) { + tools.add( + new IntegrationConnectorTool( + openApiSchemaString, pathUrl, toolName, "", null, null, null, this.httpExecutor)); + } } + } else if (!isNullOrEmpty(this.connection) + && (this.entityOperations != null || this.actions != null)) { + IntegrationClient integrationClient = + new IntegrationClient( + this.project, + this.location, + null, + null, + this.connection, + this.entityOperations, + this.actions, + this.httpExecutor); + ObjectNode parentOpenApiSpec = OBJECT_MAPPER.createObjectNode(); + ObjectNode openApiSpec = + integrationClient.getOpenApiSpecForConnection(toolNamePrefix, toolInstructions); + String openApiSpecString = OBJECT_MAPPER.writeValueAsString(openApiSpec); + parentOpenApiSpec.put("openApiSpec", openApiSpecString); + openApiSchemaString = OBJECT_MAPPER.writeValueAsString(parentOpenApiSpec); + List pathUrls = getPathUrl(openApiSchemaString); + for (String pathUrl : pathUrls) { + String toolName = integrationClient.getOperationIdFromPathUrl(openApiSchemaString, pathUrl); + if (!isNullOrEmpty(toolName)) { + ConnectionsClient connectionsClient = + new ConnectionsClient( + this.project, this.location, this.connection, this.httpExecutor, OBJECT_MAPPER); + ConnectionsClient.ConnectionDetails connectionDetails = + connectionsClient.getConnectionDetails(); + + tools.add( + new IntegrationConnectorTool( + openApiSchemaString, + pathUrl, + toolName, + "", + connectionDetails.name, + connectionDetails.serviceName, + connectionDetails.host, + this.httpExecutor)); + } + } + } else { + throw new IllegalArgumentException( + "Invalid request, Either integration or (connection and" + + " (entityOperations or actions)) should be provided."); } return tools; diff --git a/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ConnectionsClient.java b/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ConnectionsClient.java new file mode 100644 index 000000000..4d9059005 --- /dev/null +++ b/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ConnectionsClient.java @@ -0,0 +1,860 @@ +package com.google.adk.tools.applicationintegrationtoolset; + +import static com.google.common.base.Strings.isNullOrEmpty; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.tools.applicationintegrationtoolset.IntegrationConnectorTool.HttpExecutor; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Utility class for interacting with the Google Cloud Connectors API. + * + *

This class provides methods to fetch connection details, schemas for entities and actions, and + * to generate OpenAPI specifications for creating tools based on these connections. + */ +public class ConnectionsClient { + + private final String project; + private final String location; + private final String connection; + private static final String CONNECTOR_URL = "https://connectors.googleapis.com"; + private final HttpExecutor httpExecutor; + private final ObjectMapper objectMapper; + + /** Represents details of a connection. */ + public static class ConnectionDetails { + public String name; + public String serviceName; + public String host; + } + + /** Represents the schema and available operations for an entity. */ + public static class EntitySchemaAndOperations { + public Map schema; + public List operations; + } + + /** Represents the schema for an action. */ + public static class ActionSchema { + public Map inputSchema; + public Map outputSchema; + public String description; + public String displayName; + } + + /** + * Initializes the ConnectionsClient. + * + * @param project The Google Cloud project ID. + * @param location The Google Cloud location (e.g., us-central1). + * @param connection The connection name. + */ + public ConnectionsClient( + String project, + String location, + String connection, + HttpExecutor httpExecutor, + ObjectMapper objectMapper) { + this.project = project; + this.location = location; + this.connection = connection; + this.httpExecutor = httpExecutor; + this.objectMapper = objectMapper; + } + + /** + * Retrieves service details for a given connection. + * + * @return A {@link ConnectionDetails} object with the connection's info. + * @throws IOException If there is an issue with network communication or credentials. + * @throws InterruptedException If the thread is interrupted during the API call. + */ + public ConnectionDetails getConnectionDetails() throws IOException, InterruptedException { + String url = + String.format( + "%s/v1/projects/%s/locations/%s/connections/%s?view=BASIC", + CONNECTOR_URL, project, location, connection); + + HttpResponse response = executeApiCall(url); + Map connectionData = parseJson(response.body()); + + ConnectionDetails details = new ConnectionDetails(); + details.name = (String) connectionData.getOrDefault("name", ""); + details.serviceName = (String) connectionData.getOrDefault("serviceDirectory", ""); + details.host = (String) connectionData.getOrDefault("host", ""); + if (details.host != null && !details.host.isEmpty()) { + details.serviceName = (String) connectionData.getOrDefault("tlsServiceDirectory", ""); + } + return details; + } + + /** + * Retrieves the JSON schema and available operations for a given entity. + * + * @param entity The entity name. + * @return A {@link EntitySchemaAndOperations} object. + * @throws IOException If there is an issue with network communication or credentials. + * @throws InterruptedException If the thread is interrupted during polling. + */ + @SuppressWarnings("unchecked") + public EntitySchemaAndOperations getEntitySchemaAndOperations(String entity) + throws IOException, InterruptedException { + String url = + String.format( + "%s/v1/projects/%s/locations/%s/connections/%s/connectionSchemaMetadata:getEntityType?entityId=%s", + CONNECTOR_URL, project, location, connection, entity); + + HttpResponse initialResponse = executeApiCall(url); + String operationId = (String) parseJson(initialResponse.body()).get("name"); + + if (isNullOrEmpty(operationId)) { + throw new IOException("Failed to get operation ID for entity: " + entity); + } + + Map operationResponse = pollOperation(operationId); + Map responseData = + (Map) operationResponse.getOrDefault("response", ImmutableMap.of()); + + Map schema = + (Map) responseData.getOrDefault("jsonSchema", ImmutableMap.of()); + List operations = + (List) responseData.getOrDefault("operations", ImmutableList.of()); + EntitySchemaAndOperations entitySchemaAndOperations = new EntitySchemaAndOperations(); + entitySchemaAndOperations.schema = schema; + entitySchemaAndOperations.operations = operations; + return entitySchemaAndOperations; + } + + /** + * Retrieves the input and output JSON schema for a given action. + * + * @param action The action name. + * @return An {@link ActionSchema} object. + * @throws IOException If there is an issue with network communication or credentials. + * @throws InterruptedException If the thread is interrupted during polling. + */ + @SuppressWarnings("unchecked") + public ActionSchema getActionSchema(String action) throws IOException, InterruptedException { + String url = + String.format( + "%s/v1/projects/%s/locations/%s/connections/%s/connectionSchemaMetadata:getAction?actionId=%s", + CONNECTOR_URL, project, location, connection, action); + + HttpResponse initialResponse = executeApiCall(url); + String operationId = (String) parseJson(initialResponse.body()).get("name"); + + if (isNullOrEmpty(operationId)) { + throw new IOException("Failed to get operation ID for action: " + action); + } + + Map operationResponse = pollOperation(operationId); + Map responseData = + (Map) operationResponse.getOrDefault("response", ImmutableMap.of()); + + ActionSchema actionSchema = new ActionSchema(); + actionSchema.inputSchema = + (Map) responseData.getOrDefault("inputJsonSchema", ImmutableMap.of()); + actionSchema.outputSchema = + (Map) responseData.getOrDefault("outputJsonSchema", ImmutableMap.of()); + actionSchema.description = (String) responseData.getOrDefault("description", ""); + actionSchema.displayName = (String) responseData.getOrDefault("displayName", ""); + + return actionSchema; + } + + private HttpResponse executeApiCall(String url) throws IOException, InterruptedException { + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + httpExecutor.getToken()) + .GET() + .build(); + + HttpResponse response = + httpExecutor.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() >= 400) { + String body = response.body(); + if (response.statusCode() == 400 || response.statusCode() == 404) { + throw new IllegalArgumentException( + String.format( + "Invalid request. Please check the provided values of project(%s), location(%s)," + + " connection(%s). Error: %s", + project, location, connection, body)); + } + if (response.statusCode() == 401 || response.statusCode() == 403) { + throw new SecurityException( + String.format("Permission error (status %d): %s", response.statusCode(), body)); + } + throw new IOException( + String.format("API call failed with status %d: %s", response.statusCode(), body)); + } + return response; + } + + private Map pollOperation(String operationId) + throws IOException, InterruptedException { + boolean operationDone = false; + Map operationResponse = null; + + while (!operationDone) { + String getOperationUrl = String.format("%s/v1/%s", CONNECTOR_URL, operationId); + HttpResponse response = executeApiCall(getOperationUrl); + operationResponse = parseJson(response.body()); + + Object doneObj = operationResponse.get("done"); + if (doneObj instanceof Boolean b) { + operationDone = b; + } + + if (!operationDone) { + Thread.sleep(1000); + } + } + return operationResponse; + } + + /** + * Converts a JSON Schema dictionary to an OpenAPI schema dictionary. + * + * @param jsonSchema The input JSON schema map. + * @return The converted OpenAPI schema map. + */ + public Map convertJsonSchemaToOpenApiSchema(Map jsonSchema) { + Map openapiSchema = new HashMap<>(); + + if (jsonSchema.containsKey("description")) { + openapiSchema.put("description", jsonSchema.get("description")); + } + + if (jsonSchema.containsKey("type")) { + Object type = jsonSchema.get("type"); + if (type instanceof List) { + List typeList = (List) type; + if (typeList.contains("null")) { + openapiSchema.put("nullable", true); + typeList.stream() + .filter(t -> t instanceof String && !t.equals("null")) + .findFirst() + .ifPresent(t -> openapiSchema.put("type", t)); + } else if (!typeList.isEmpty()) { + openapiSchema.put("type", typeList.get(0)); + } + } else { + openapiSchema.put("type", type); + } + } + if (Objects.equals(openapiSchema.get("type"), "object") + && jsonSchema.containsKey("properties")) { + @SuppressWarnings("unchecked") + Map> properties = + (Map>) jsonSchema.get("properties"); + Map convertedProperties = new HashMap<>(); + for (Map.Entry> entry : properties.entrySet()) { + convertedProperties.put(entry.getKey(), convertJsonSchemaToOpenApiSchema(entry.getValue())); + } + openapiSchema.put("properties", convertedProperties); + } else if (Objects.equals(openapiSchema.get("type"), "array") + && jsonSchema.containsKey("items")) { + @SuppressWarnings("unchecked") + Map itemsSchema = (Map) jsonSchema.get("items"); + openapiSchema.put("items", convertJsonSchemaToOpenApiSchema(itemsSchema)); + } + + return openapiSchema; + } + + public Map connectorPayload(Map jsonSchema) { + return convertJsonSchemaToOpenApiSchema(jsonSchema); + } + + private Map parseJson(String json) throws IOException { + return objectMapper.readValue(json, new TypeReference<>() {}); + } + + public static ImmutableMap getConnectorBaseSpec() { + return ImmutableMap.ofEntries( + Map.entry("openapi", "3.0.1"), + Map.entry( + "info", + ImmutableMap.of( + "title", "ExecuteConnection", + "description", "This tool can execute a query on connection", + "version", "4")), + Map.entry( + "servers", + ImmutableList.of(ImmutableMap.of("url", "https://integrations.googleapis.com"))), + Map.entry( + "security", + ImmutableList.of( + ImmutableMap.of( + "google_auth", + ImmutableList.of("https://www.googleapis.com/auth/cloud-platform")))), + Map.entry("paths", ImmutableMap.of()), + Map.entry( + "components", + ImmutableMap.ofEntries( + Map.entry( + "schemas", + ImmutableMap.ofEntries( + Map.entry( + "operation", + ImmutableMap.of( + "type", "string", + "default", "LIST_ENTITIES", + "description", + "Operation to execute. Possible values are LIST_ENTITIES," + + " GET_ENTITY, CREATE_ENTITY, UPDATE_ENTITY, DELETE_ENTITY" + + " in case of entities. EXECUTE_ACTION in case of" + + " actions. and EXECUTE_QUERY in case of custom" + + " queries.")), + Map.entry( + "entityId", + ImmutableMap.of("type", "string", "description", "Name of the entity")), + Map.entry("connectorInputPayload", ImmutableMap.of("type", "object")), + Map.entry( + "filterClause", + ImmutableMap.of( + "type", "string", + "default", "", + "description", "WHERE clause in SQL query")), + Map.entry( + "pageSize", + ImmutableMap.of( + "type", "integer", + "default", 50, + "description", "Number of entities to return in the response")), + Map.entry( + "pageToken", + ImmutableMap.of( + "type", "string", + "default", "", + "description", "Page token to return the next page of entities")), + Map.entry( + "connectionName", + ImmutableMap.of( + "type", "string", + "default", "", + "description", "Connection resource name to run the query for")), + Map.entry( + "serviceName", + ImmutableMap.of( + "type", "string", + "default", "", + "description", "Service directory for the connection")), + Map.entry( + "host", + ImmutableMap.of( + "type", "string", + "default", "", + "description", "Host name incase of tls service directory")), + Map.entry( + "entity", + ImmutableMap.of( + "type", "string", + "default", "Issues", + "description", "Entity to run the query for")), + Map.entry( + "action", + ImmutableMap.of( + "type", "string", + "default", "ExecuteCustomQuery", + "description", "Action to run the query for")), + Map.entry( + "query", + ImmutableMap.of( + "type", "string", + "default", "", + "description", "Custom Query to execute on the connection")), + Map.entry( + "timeout", + ImmutableMap.of( + "type", "integer", + "default", 120, + "description", "Timeout in seconds for execution of custom query")), + Map.entry( + "sortByColumns", + ImmutableMap.of( + "type", + "array", + "items", + ImmutableMap.of("type", "string"), + "default", + ImmutableList.of(), + "description", + "Column to sort the results by")), + Map.entry("connectorOutputPayload", ImmutableMap.of("type", "object")), + Map.entry("nextPageToken", ImmutableMap.of("type", "string")), + Map.entry( + "execute-connector_Response", + ImmutableMap.of( + "required", ImmutableList.of("connectorOutputPayload"), + "type", "object", + "properties", + ImmutableMap.of( + "connectorOutputPayload", + ImmutableMap.of( + "$ref", "#/components/schemas/connectorOutputPayload"), + "nextPageToken", + ImmutableMap.of( + "$ref", "#/components/schemas/nextPageToken")))))), + Map.entry( + "securitySchemes", + ImmutableMap.of( + "google_auth", + ImmutableMap.of( + "type", + "oauth2", + "flows", + ImmutableMap.of( + "implicit", + ImmutableMap.of( + "authorizationUrl", + "https://accounts.google.com/o/oauth2/auth", + "scopes", + ImmutableMap.of( + "https://www.googleapis.com/auth/cloud-platform", + "Auth for google cloud services"))))))))); + } + + public static ImmutableMap getActionOperation( + String action, + String operation, + String actionDisplayName, + String toolName, + String toolInstructions) { + String description = "Use this tool to execute " + action; + if (Objects.equals(operation, "EXECUTE_QUERY")) { + description += + " Use pageSize = 50 and timeout = 120 until user specifies a different value" + + " otherwise. If user provides a query in natural language, convert it to SQL query" + + " and then execute it using the tool."; + } + + return ImmutableMap.of( + "post", + ImmutableMap.ofEntries( + Map.entry("summary", actionDisplayName), + Map.entry("description", description + " " + toolInstructions), + Map.entry("operationId", toolName + "_" + actionDisplayName), + Map.entry("x-action", action), + Map.entry("x-operation", operation), + Map.entry( + "requestBody", + ImmutableMap.of( + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", + String.format( + "#/components/schemas/%s_Request", actionDisplayName)))))), + Map.entry( + "responses", + ImmutableMap.of( + "200", + ImmutableMap.of( + "description", + "Success response", + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", + String.format( + "#/components/schemas/%s_Response", + actionDisplayName))))))))); + } + + public static ImmutableMap listOperation( + String entity, String schemaAsString, String toolName, String toolInstructions) { + return ImmutableMap.of( + "post", + ImmutableMap.ofEntries( + Map.entry("summary", "List " + entity), + Map.entry( + "description", + String.format( + "Returns the list of %s data. If the page token was available in the response," + + " let users know there are more records available. Ask if the user wants" + + " to fetch the next page of results. When passing filter use the" + + " following format: `field_name1='value1' AND field_name2='value2'`. %s", + entity, toolInstructions)), + Map.entry("x-operation", "LIST_ENTITIES"), + Map.entry("x-entity", entity), + Map.entry("operationId", toolName + "_list_" + entity), + Map.entry( + "requestBody", + ImmutableMap.of( + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", "#/components/schemas/list_" + entity + "_Request"))))), + Map.entry( + "responses", + ImmutableMap.of( + "200", + ImmutableMap.of( + "description", + "Success response", + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "description", + String.format( + "Returns a list of %s of json schema: %s", + entity, schemaAsString), + "$ref", + "#/components/schemas/execute-connector_Response")))))))); + } + + public static ImmutableMap getOperation( + String entity, String schemaAsString, String toolName, String toolInstructions) { + return ImmutableMap.of( + "post", + ImmutableMap.ofEntries( + Map.entry("summary", "Get " + entity), + Map.entry( + "description", + String.format("Returns the details of the %s. %s", entity, toolInstructions)), + Map.entry("operationId", toolName + "_get_" + entity), + Map.entry("x-operation", "GET_ENTITY"), + Map.entry("x-entity", entity), + Map.entry( + "requestBody", + ImmutableMap.of( + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", "#/components/schemas/get_" + entity + "_Request"))))), + Map.entry( + "responses", + ImmutableMap.of( + "200", + ImmutableMap.of( + "description", + "Success response", + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "description", + String.format( + "Returns %s of json schema: %s", entity, schemaAsString), + "$ref", + "#/components/schemas/execute-connector_Response")))))))); + } + + public static ImmutableMap createOperation( + String entity, String toolName, String toolInstructions) { + return ImmutableMap.of( + "post", + ImmutableMap.ofEntries( + Map.entry("summary", "Creates a new " + entity), + Map.entry( + "description", String.format("Creates a new %s. %s", entity, toolInstructions)), + Map.entry("x-operation", "CREATE_ENTITY"), + Map.entry("x-entity", entity), + Map.entry("operationId", toolName + "_create_" + entity), + Map.entry( + "requestBody", + ImmutableMap.of( + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", "#/components/schemas/create_" + entity + "_Request"))))), + Map.entry( + "responses", + ImmutableMap.of( + "200", + ImmutableMap.of( + "description", + "Success response", + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", + "#/components/schemas/execute-connector_Response")))))))); + } + + public static ImmutableMap updateOperation( + String entity, String toolName, String toolInstructions) { + return ImmutableMap.of( + "post", + ImmutableMap.ofEntries( + Map.entry("summary", "Updates the " + entity), + Map.entry("description", String.format("Updates the %s. %s", entity, toolInstructions)), + Map.entry("x-operation", "UPDATE_ENTITY"), + Map.entry("x-entity", entity), + Map.entry("operationId", toolName + "_update_" + entity), + Map.entry( + "requestBody", + ImmutableMap.of( + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", "#/components/schemas/update_" + entity + "_Request"))))), + Map.entry( + "responses", + ImmutableMap.of( + "200", + ImmutableMap.of( + "description", + "Success response", + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", + "#/components/schemas/execute-connector_Response")))))))); + } + + public static ImmutableMap deleteOperation( + String entity, String toolName, String toolInstructions) { + return ImmutableMap.of( + "post", + ImmutableMap.ofEntries( + Map.entry("summary", "Delete the " + entity), + Map.entry("description", String.format("Deletes the %s. %s", entity, toolInstructions)), + Map.entry("x-operation", "DELETE_ENTITY"), + Map.entry("x-entity", entity), + Map.entry("operationId", toolName + "_delete_" + entity), + Map.entry( + "requestBody", + ImmutableMap.of( + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", "#/components/schemas/delete_" + entity + "_Request"))))), + Map.entry( + "responses", + ImmutableMap.of( + "200", + ImmutableMap.of( + "description", + "Success response", + "content", + ImmutableMap.of( + "application/json", + ImmutableMap.of( + "schema", + ImmutableMap.of( + "$ref", + "#/components/schemas/execute-connector_Response")))))))); + } + + public static ImmutableMap createOperationRequest(String entity) { + return ImmutableMap.of( + "type", + "object", + "required", + ImmutableList.of( + "connectorInputPayload", + "operation", + "connectionName", + "serviceName", + "host", + "entity"), + "properties", + ImmutableMap.ofEntries( + Map.entry( + "connectorInputPayload", + ImmutableMap.of("$ref", "#/components/schemas/connectorInputPayload_" + entity)), + Map.entry("operation", ImmutableMap.of("$ref", "#/components/schemas/operation")), + Map.entry( + "connectionName", ImmutableMap.of("$ref", "#/components/schemas/connectionName")), + Map.entry("serviceName", ImmutableMap.of("$ref", "#/components/schemas/serviceName")), + Map.entry("host", ImmutableMap.of("$ref", "#/components/schemas/host")), + Map.entry("entity", ImmutableMap.of("$ref", "#/components/schemas/entity")))); + } + + public static ImmutableMap updateOperationRequest(String entity) { + return ImmutableMap.of( + "type", + "object", + "required", + ImmutableList.of( + "connectorInputPayload", + "entityId", + "operation", + "connectionName", + "serviceName", + "host", + "entity"), + "properties", + ImmutableMap.ofEntries( + Map.entry( + "connectorInputPayload", + ImmutableMap.of("$ref", "#/components/schemas/connectorInputPayload_" + entity)), + Map.entry("entityId", ImmutableMap.of("$ref", "#/components/schemas/entityId")), + Map.entry("operation", ImmutableMap.of("$ref", "#/components/schemas/operation")), + Map.entry( + "connectionName", ImmutableMap.of("$ref", "#/components/schemas/connectionName")), + Map.entry("serviceName", ImmutableMap.of("$ref", "#/components/schemas/serviceName")), + Map.entry("host", ImmutableMap.of("$ref", "#/components/schemas/host")), + Map.entry("entity", ImmutableMap.of("$ref", "#/components/schemas/entity")), + Map.entry( + "filterClause", ImmutableMap.of("$ref", "#/components/schemas/filterClause")))); + } + + public static ImmutableMap getOperationRequest() { + return ImmutableMap.of( + "type", + "object", + "required", + ImmutableList.of( + "entityId", "operation", "connectionName", "serviceName", "host", "entity"), + "properties", + ImmutableMap.ofEntries( + Map.entry("entityId", ImmutableMap.of("$ref", "#/components/schemas/entityId")), + Map.entry("operation", ImmutableMap.of("$ref", "#/components/schemas/operation")), + Map.entry( + "connectionName", ImmutableMap.of("$ref", "#/components/schemas/connectionName")), + Map.entry("serviceName", ImmutableMap.of("$ref", "#/components/schemas/serviceName")), + Map.entry("host", ImmutableMap.of("$ref", "#/components/schemas/host")), + Map.entry("entity", ImmutableMap.of("$ref", "#/components/schemas/entity")))); + } + + public static ImmutableMap deleteOperationRequest() { + return ImmutableMap.of( + "type", + "object", + "required", + ImmutableList.of( + "entityId", "operation", "connectionName", "serviceName", "host", "entity"), + "properties", + ImmutableMap.ofEntries( + Map.entry("entityId", ImmutableMap.of("$ref", "#/components/schemas/entityId")), + Map.entry("operation", ImmutableMap.of("$ref", "#/components/schemas/operation")), + Map.entry( + "connectionName", ImmutableMap.of("$ref", "#/components/schemas/connectionName")), + Map.entry("serviceName", ImmutableMap.of("$ref", "#/components/schemas/serviceName")), + Map.entry("host", ImmutableMap.of("$ref", "#/components/schemas/host")), + Map.entry("entity", ImmutableMap.of("$ref", "#/components/schemas/entity")), + Map.entry( + "filterClause", ImmutableMap.of("$ref", "#/components/schemas/filterClause")))); + } + + public static ImmutableMap listOperationRequest() { + return ImmutableMap.of( + "type", + "object", + "required", + ImmutableList.of("operation", "connectionName", "serviceName", "host", "entity"), + "properties", + ImmutableMap.ofEntries( + Map.entry("filterClause", ImmutableMap.of("$ref", "#/components/schemas/filterClause")), + Map.entry("pageSize", ImmutableMap.of("$ref", "#/components/schemas/pageSize")), + Map.entry("pageToken", ImmutableMap.of("$ref", "#/components/schemas/pageToken")), + Map.entry("operation", ImmutableMap.of("$ref", "#/components/schemas/operation")), + Map.entry( + "connectionName", ImmutableMap.of("$ref", "#/components/schemas/connectionName")), + Map.entry("serviceName", ImmutableMap.of("$ref", "#/components/schemas/serviceName")), + Map.entry("host", ImmutableMap.of("$ref", "#/components/schemas/host")), + Map.entry("entity", ImmutableMap.of("$ref", "#/components/schemas/entity")), + Map.entry( + "sortByColumns", ImmutableMap.of("$ref", "#/components/schemas/sortByColumns")))); + } + + public static ImmutableMap actionRequest(String action) { + return ImmutableMap.of( + "type", + "object", + "required", + ImmutableList.of( + "operation", + "connectionName", + "serviceName", + "host", + "action", + "connectorInputPayload"), + "properties", + ImmutableMap.ofEntries( + Map.entry("operation", ImmutableMap.of("$ref", "#/components/schemas/operation")), + Map.entry( + "connectionName", ImmutableMap.of("$ref", "#/components/schemas/connectionName")), + Map.entry("serviceName", ImmutableMap.of("$ref", "#/components/schemas/serviceName")), + Map.entry("host", ImmutableMap.of("$ref", "#/components/schemas/host")), + Map.entry("action", ImmutableMap.of("$ref", "#/components/schemas/action")), + Map.entry( + "connectorInputPayload", + ImmutableMap.of("$ref", "#/components/schemas/connectorInputPayload_" + action)))); + } + + public static ImmutableMap actionResponse(String action) { + return ImmutableMap.of( + "type", + "object", + "properties", + ImmutableMap.of( + "connectorOutputPayload", + ImmutableMap.of("$ref", "#/components/schemas/connectorOutputPayload_" + action))); + } + + public static ImmutableMap executeCustomQueryRequest() { + return ImmutableMap.of( + "type", + "object", + "required", + ImmutableList.of( + "operation", + "connectionName", + "serviceName", + "host", + "action", + "query", + "timeout", + "pageSize"), + "properties", + ImmutableMap.ofEntries( + Map.entry("operation", ImmutableMap.of("$ref", "#/components/schemas/operation")), + Map.entry( + "connectionName", ImmutableMap.of("$ref", "#/components/schemas/connectionName")), + Map.entry("serviceName", ImmutableMap.of("$ref", "#/components/schemas/serviceName")), + Map.entry("host", ImmutableMap.of("$ref", "#/components/schemas/host")), + Map.entry("action", ImmutableMap.of("$ref", "#/components/schemas/action")), + Map.entry("query", ImmutableMap.of("$ref", "#/components/schemas/query")), + Map.entry("timeout", ImmutableMap.of("$ref", "#/components/schemas/timeout")), + Map.entry("pageSize", ImmutableMap.of("$ref", "#/components/schemas/pageSize")))); + } +} diff --git a/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationClient.java b/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationClient.java new file mode 100644 index 000000000..2f43458c0 --- /dev/null +++ b/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationClient.java @@ -0,0 +1,331 @@ +package com.google.adk.tools.applicationintegrationtoolset; + +import static com.google.common.base.Strings.isNullOrEmpty; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.adk.tools.applicationintegrationtoolset.IntegrationConnectorTool.HttpExecutor; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Utility class for interacting with Google Cloud Application Integration. + * + *

This class provides methods for retrieving OpenAPI spec for an integration or a connection. + */ +public class IntegrationClient { + String project; + String location; + String integration; + List triggers; + String connection; + Map> entityOperations; + List actions; + private final HttpExecutor httpExecutor; + public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + IntegrationClient( + String project, + String location, + String integration, + List triggers, + String connection, + Map> entityOperations, + List actions, + HttpExecutor httpExecutor) { + this.project = project; + this.location = location; + this.integration = integration; + this.triggers = triggers; + this.connection = connection; + this.entityOperations = entityOperations; + this.actions = actions; + this.httpExecutor = httpExecutor; + if (!isNullOrEmpty(connection)) { + validate(); + } + } + + private void validate() { + // Check if both are null, throw exception + + if (this.entityOperations == null && this.actions == null) { + throw new IllegalArgumentException( + "No entity operations or actions provided. Please provide at least one of them."); + } + + if (this.entityOperations != null) { + Preconditions.checkArgument( + !this.entityOperations.isEmpty(), "entityOperations map cannot be empty"); + for (Map.Entry> entry : this.entityOperations.entrySet()) { + String key = entry.getKey(); + List value = entry.getValue(); + Preconditions.checkArgument( + key != null && !key.isEmpty(), + "Enitity in entityOperations map cannot be null or empty"); + Preconditions.checkArgument( + value != null, "Operations for entity '%s' cannot be null", key); + for (String str : value) { + Preconditions.checkArgument( + str != null && !str.isEmpty(), + "Operation for entity '%s' cannot be null or empty", + key); + } + } + } + + // Validate actions if it's not null + if (this.actions != null) { + Preconditions.checkArgument(!this.actions.isEmpty(), "Actions list cannot be empty"); + Preconditions.checkArgument( + this.actions.stream().allMatch(Objects::nonNull), + "Actions list cannot contain null values"); + Preconditions.checkArgument( + this.actions.stream().noneMatch(String::isEmpty), + "Actions list cannot contain empty strings"); + } + } + + String generateOpenApiSpec() throws Exception { + String url = + String.format( + "https://%s-integrations.googleapis.com/v1/projects/%s/locations/%s:generateOpenApiSpec", + this.location, this.project, this.location); + + String jsonRequestBody = + OBJECT_MAPPER.writeValueAsString( + ImmutableMap.of( + "apiTriggerResources", + ImmutableList.of( + ImmutableMap.of( + "integrationResource", + this.integration, + "triggerId", + Arrays.asList(this.triggers))), + "fileFormat", + "JSON")); + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + httpExecutor.getToken()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonRequestBody)) + .build(); + HttpResponse response = + httpExecutor.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new Exception("Error fetching OpenAPI spec. Status: " + response.statusCode()); + } + return response.body(); + } + + @SuppressWarnings("unchecked") + ObjectNode getOpenApiSpecForConnection(String toolName, String toolInstructions) + throws IOException, InterruptedException { + final String integrationName = "ExecuteConnection"; + + ConnectionsClient connectionsClient = createConnectionsClient(); + + ImmutableMap baseSpecMap = ConnectionsClient.getConnectorBaseSpec(); + ObjectNode connectorSpec = OBJECT_MAPPER.valueToTree(baseSpecMap); + + ObjectNode paths = (ObjectNode) connectorSpec.path("paths"); + ObjectNode schemas = (ObjectNode) connectorSpec.path("components").path("schemas"); + + if (this.entityOperations != null) { + for (Map.Entry> entry : this.entityOperations.entrySet()) { + String entity = entry.getKey(); + List operations = entry.getValue(); + + ConnectionsClient.EntitySchemaAndOperations schemaInfo; + try { + schemaInfo = connectionsClient.getEntitySchemaAndOperations(entity); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Operation was interrupted while getting entity schema", e); + } + + Map schemaMap = schemaInfo.schema; + List supportedOperations = schemaInfo.operations; + + if (operations == null || operations.isEmpty()) { + operations = supportedOperations; + } + + String jsonSchemaAsString = OBJECT_MAPPER.writeValueAsString(schemaMap); + String entityLower = entity.toLowerCase(Locale.ROOT); + + schemas.set( + "connectorInputPayload_" + entityLower, + OBJECT_MAPPER.valueToTree(connectionsClient.connectorPayload(schemaMap))); + + for (String operation : operations) { + String operationLower = operation.toLowerCase(Locale.ROOT); + String path = + String.format( + "/v2/projects/%s/locations/%s/integrations/%s:execute?triggerId=api_trigger/%s#%s_%s", + this.project, + this.location, + integrationName, + integrationName, + operationLower, + entityLower); + + switch (operationLower) { + case "create": + paths.set( + path, + OBJECT_MAPPER.valueToTree( + ConnectionsClient.createOperation(entityLower, toolName, toolInstructions))); + schemas.set( + "create_" + entityLower + "_Request", + OBJECT_MAPPER.valueToTree(ConnectionsClient.createOperationRequest(entityLower))); + break; + case "update": + paths.set( + path, + OBJECT_MAPPER.valueToTree( + ConnectionsClient.updateOperation(entityLower, toolName, toolInstructions))); + schemas.set( + "update_" + entityLower + "_Request", + OBJECT_MAPPER.valueToTree(ConnectionsClient.updateOperationRequest(entityLower))); + break; + case "delete": + paths.set( + path, + OBJECT_MAPPER.valueToTree( + ConnectionsClient.deleteOperation(entityLower, toolName, toolInstructions))); + schemas.set( + "delete_" + entityLower + "_Request", + OBJECT_MAPPER.valueToTree(ConnectionsClient.deleteOperationRequest())); + break; + case "list": + paths.set( + path, + OBJECT_MAPPER.valueToTree( + ConnectionsClient.listOperation( + entityLower, jsonSchemaAsString, toolName, toolInstructions))); + schemas.set( + "list_" + entityLower + "_Request", + OBJECT_MAPPER.valueToTree(ConnectionsClient.listOperationRequest())); + break; + case "get": + paths.set( + path, + OBJECT_MAPPER.valueToTree( + ConnectionsClient.getOperation( + entityLower, jsonSchemaAsString, toolName, toolInstructions))); + schemas.set( + "get_" + entityLower + "_Request", + OBJECT_MAPPER.valueToTree(ConnectionsClient.getOperationRequest())); + break; + default: + throw new IllegalArgumentException( + "Invalid operation: " + operation + " for entity: " + entity); + } + } + } + } else if (this.actions != null) { + for (String action : this.actions) { + ObjectNode actionDetails = + OBJECT_MAPPER.valueToTree(connectionsClient.getActionSchema(action)); + + JsonNode inputSchemaNode = actionDetails.path("inputSchema"); + JsonNode outputSchemaNode = actionDetails.path("outputSchema"); + + String actionDisplayName = actionDetails.path("displayName").asText("").replace(" ", ""); + String operation = "EXECUTE_ACTION"; + + Map inputSchemaMap = OBJECT_MAPPER.treeToValue(inputSchemaNode, Map.class); + Map outputSchemaMap = + OBJECT_MAPPER.treeToValue(outputSchemaNode, Map.class); + + if (Objects.equals(action, "ExecuteCustomQuery")) { + schemas.set( + actionDisplayName + "_Request", + OBJECT_MAPPER.valueToTree(ConnectionsClient.executeCustomQueryRequest())); + operation = "EXECUTE_QUERY"; + } else { + schemas.set( + actionDisplayName + "_Request", + OBJECT_MAPPER.valueToTree(ConnectionsClient.actionRequest(actionDisplayName))); + schemas.set( + "connectorInputPayload_" + actionDisplayName, + OBJECT_MAPPER.valueToTree(connectionsClient.connectorPayload(inputSchemaMap))); + } + + schemas.set( + "connectorOutputPayload_" + actionDisplayName, + OBJECT_MAPPER.valueToTree(connectionsClient.connectorPayload(outputSchemaMap))); + schemas.set( + actionDisplayName + "_Response", + OBJECT_MAPPER.valueToTree(ConnectionsClient.actionResponse(actionDisplayName))); + + String path = + String.format( + "/v2/projects/%s/locations/%s/integrations/%s:execute?triggerId=api_trigger/%s#%s", + this.project, this.location, integrationName, integrationName, action); + + paths.set( + path, + OBJECT_MAPPER.valueToTree( + ConnectionsClient.getActionOperation( + action, operation, actionDisplayName, toolName, toolInstructions))); + } + } else { + throw new IllegalArgumentException( + "No entity operations or actions provided. Please provide at least one of them."); + } + return connectorSpec; + } + + String getOperationIdFromPathUrl(String openApiSchemaString, String pathUrl) throws Exception { + JsonNode topLevelNode = OBJECT_MAPPER.readTree(openApiSchemaString); + JsonNode specNode = topLevelNode.path("openApiSpec"); + if (specNode.isMissingNode() || !specNode.isTextual()) { + throw new IllegalArgumentException( + "Failed to get OpenApiSpec, please check the project and region for the integration."); + } + JsonNode rootNode = OBJECT_MAPPER.readTree(specNode.asText()); + JsonNode paths = rootNode.path("paths"); + + Iterator> pathsFields = paths.fields(); + while (pathsFields.hasNext()) { + Map.Entry pathEntry = pathsFields.next(); + String currentPath = pathEntry.getKey(); + if (!currentPath.equals(pathUrl)) { + continue; + } + JsonNode pathItem = pathEntry.getValue(); + + Iterator> methods = pathItem.fields(); + while (methods.hasNext()) { + Map.Entry methodEntry = methods.next(); + JsonNode operationNode = methodEntry.getValue(); + + if (operationNode.has("operationId")) { + return operationNode.path("operationId").asText(); + } + } + } + throw new Exception("Could not find operationId for pathUrl: " + pathUrl); + } + + ConnectionsClient createConnectionsClient() { + return new ConnectionsClient( + this.project, this.location, this.connection, this.httpExecutor, OBJECT_MAPPER); + } +} diff --git a/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationTool.java b/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationConnectorTool.java similarity index 56% rename from core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationTool.java rename to core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationConnectorTool.java index f94d50ff4..00a433f17 100644 --- a/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationTool.java +++ b/core/src/main/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationConnectorTool.java @@ -1,12 +1,17 @@ package com.google.adk.tools.applicationintegrationtoolset; +import static com.google.common.base.Strings.isNullOrEmpty; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.adk.tools.BaseTool; import com.google.adk.tools.ToolContext; import com.google.auth.oauth2.GoogleCredentials; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Streams; import com.google.genai.types.FunctionDeclaration; import com.google.genai.types.Schema; import io.reactivex.rxjava3.core.Single; @@ -16,21 +21,30 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Optional; import org.jspecify.annotations.Nullable; /** Application Integration Tool */ -public class ApplicationIntegrationTool extends BaseTool { +public class IntegrationConnectorTool extends BaseTool { private final String openApiSpec; private final String pathUrl; private final HttpExecutor httpExecutor; + private final String connectionName; + private final String serviceName; + private final String host; + private String entity; + private String operation; + private String action; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); interface HttpExecutor { HttpResponse send(HttpRequest request, HttpResponse.BodyHandler responseBodyHandler) throws IOException, InterruptedException; + + String getToken() throws IOException; } static class DefaultHttpExecutor implements HttpExecutor { @@ -42,23 +56,75 @@ public HttpResponse send( throws IOException, InterruptedException { return client.send(request, responseBodyHandler); } + + @Override + public String getToken() throws IOException { + GoogleCredentials credentials = + GoogleCredentials.getApplicationDefault() + .createScoped("https://www.googleapis.com/auth/cloud-platform"); + credentials.refreshIfExpired(); + return credentials.getAccessToken().getTokenValue(); + } } - public ApplicationIntegrationTool( + private static final ImmutableList EXCLUDE_FIELDS = + ImmutableList.of("connectionName", "serviceName", "host", "entity", "operation", "action"); + + private static final ImmutableList OPTIONAL_FIELDS = + ImmutableList.of("pageSize", "pageToken", "filter", "sortByColumns"); + + /** Constructor for Application Integration Tool for integration */ + IntegrationConnectorTool( String openApiSpec, String pathUrl, String toolName, String toolDescription) { - // Chain to the internal constructor, providing real dependencies. - this(openApiSpec, pathUrl, toolName, toolDescription, new DefaultHttpExecutor()); + this( + openApiSpec, + pathUrl, + toolName, + toolDescription, + null, + null, + null, + new DefaultHttpExecutor()); + } + + /** + * Constructor for Application Integration Tool with connection name, service name, host, entity, + * operation, and action + */ + IntegrationConnectorTool( + String openApiSpec, + String pathUrl, + String toolName, + String toolDescription, + String connectionName, + String serviceName, + String host) { + this( + openApiSpec, + pathUrl, + toolName, + toolDescription, + connectionName, + serviceName, + host, + new DefaultHttpExecutor()); } - ApplicationIntegrationTool( + IntegrationConnectorTool( String openApiSpec, String pathUrl, String toolName, String toolDescription, + @Nullable String connectionName, + @Nullable String serviceName, + @Nullable String host, HttpExecutor httpExecutor) { super(toolName, toolDescription); this.openApiSpec = openApiSpec; this.pathUrl = pathUrl; + this.connectionName = connectionName; + this.serviceName = serviceName; + this.host = host; this.httpExecutor = httpExecutor; } @@ -67,19 +133,10 @@ Schema toGeminiSchema(String openApiSchema, String operationId) throws Exception return Schema.fromJson(resolvedSchemaString); } - @Nullable String extractTriggerIdFromPath(String path) { - String prefix = "triggerId=api_trigger/"; - int startIndex = path.indexOf(prefix); - if (startIndex == -1) { - return null; - } - return path.substring(startIndex + prefix.length()); - } - @Override public Optional declaration() { try { - String operationId = extractTriggerIdFromPath(pathUrl); + String operationId = getOperationIdFromPathUrl(openApiSpec, pathUrl); Schema parametersSchema = toGeminiSchema(openApiSpec, operationId); String operationDescription = getOperationDescription(openApiSpec, operationId); @@ -98,6 +155,18 @@ public Optional declaration() { @Override public Single> runAsync(Map args, ToolContext toolContext) { + if (this.connectionName != null) { + args.put("connectionName", this.connectionName); + args.put("serviceName", this.serviceName); + args.put("host", this.host); + if (!isNullOrEmpty(this.entity) && !isNullOrEmpty(this.operation)) { + args.put("entity", this.entity); + args.put("operation", this.operation); + } else if (!isNullOrEmpty(this.action)) { + args.put("action", this.action); + } + } + return Single.fromCallable( () -> { try { @@ -121,7 +190,7 @@ private String executeIntegration(Map args) throws Exception { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) - .header("Authorization", "Bearer " + getAccessToken()) + .header("Authorization", "Bearer " + httpExecutor.getToken()) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(jsonRequestBody)) .build(); @@ -138,12 +207,47 @@ private String executeIntegration(Map args) throws Exception { return response.body(); } - String getAccessToken() throws IOException { - GoogleCredentials credentials = - GoogleCredentials.getApplicationDefault() - .createScoped("https://www.googleapis.com/auth/cloud-platform"); - credentials.refreshIfExpired(); - return credentials.getAccessToken().getTokenValue(); + String getOperationIdFromPathUrl(String openApiSchemaString, String pathUrl) throws Exception { + JsonNode topLevelNode = OBJECT_MAPPER.readTree(openApiSchemaString); + JsonNode specNode = topLevelNode.path("openApiSpec"); + if (specNode.isMissingNode() || !specNode.isTextual()) { + throw new IllegalArgumentException( + "Failed to get OpenApiSpec, please check the project and region for the integration."); + } + JsonNode rootNode = OBJECT_MAPPER.readTree(specNode.asText()); + JsonNode paths = rootNode.path("paths"); + + // Iterate through each path in the OpenAPI spec. + Iterator> pathsFields = paths.fields(); + while (pathsFields.hasNext()) { + Map.Entry pathEntry = pathsFields.next(); + String currentPath = pathEntry.getKey(); + if (!currentPath.equals(pathUrl)) { + continue; + } + JsonNode pathItem = pathEntry.getValue(); + + Iterator> methods = pathItem.fields(); + while (methods.hasNext()) { + Map.Entry methodEntry = methods.next(); + JsonNode operationNode = methodEntry.getValue(); + // Set values for entity, operation, and action + this.entity = ""; + this.operation = ""; + this.action = ""; + if (operationNode.has("x-entity")) { + this.entity = operationNode.path("x-entity").asText(); + this.operation = operationNode.path("x-operation").asText(); + } else if (operationNode.has("x-action")) { + this.action = operationNode.path("x-action").asText(); + } + // Get the operationId from the operationNode + if (operationNode.has("operationId")) { + return operationNode.path("operationId").asText(); + } + } + } + throw new Exception("Could not find operationId for pathUrl: " + pathUrl); } private String getResolvedRequestSchemaByOperationId( @@ -155,7 +259,6 @@ private String getResolvedRequestSchemaByOperationId( "Failed to get OpenApiSpec, please check the project and region for the integration."); } JsonNode rootNode = OBJECT_MAPPER.readTree(specNode.asText()); - JsonNode operationNode = findOperationNodeById(rootNode, operationId); if (operationNode == null) { throw new Exception("Could not find operation with operationId: " + operationId); @@ -169,19 +272,47 @@ private String getResolvedRequestSchemaByOperationId( JsonNode resolvedSchema = resolveRefs(requestSchemaNode, rootNode); + if (resolvedSchema.isObject()) { + ObjectNode schemaObject = (ObjectNode) resolvedSchema; + + // 1. Remove excluded fields from the 'properties' object. + JsonNode propertiesNode = schemaObject.path("properties"); + if (propertiesNode.isObject()) { + ObjectNode propertiesObject = (ObjectNode) propertiesNode; + for (String field : EXCLUDE_FIELDS) { + propertiesObject.remove(field); + } + } + + // 2. Remove optional and excluded fields from the 'required' array. + JsonNode requiredNode = schemaObject.path("required"); + if (requiredNode.isArray()) { + // Combine the lists of fields to remove + List fieldsToRemove = + Streams.concat(OPTIONAL_FIELDS.stream(), EXCLUDE_FIELDS.stream()).toList(); + + // To safely remove items from a list while iterating, we must use an Iterator. + ArrayNode requiredArray = (ArrayNode) requiredNode; + Iterator elements = requiredArray.elements(); + while (elements.hasNext()) { + JsonNode element = elements.next(); + if (element.isTextual() && fieldsToRemove.contains(element.asText())) { + // This removes the current element from the underlying array. + elements.remove(); + } + } + } + } return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(resolvedSchema); } private @Nullable JsonNode findOperationNodeById(JsonNode rootNode, String operationId) { JsonNode paths = rootNode.path("paths"); - // Iterate through each path in the OpenAPI spec. for (JsonNode pathItem : paths) { - // Iterate through each HTTP method (e.g., GET, POST) for the current path. Iterator> methods = pathItem.fields(); while (methods.hasNext()) { Map.Entry methodEntry = methods.next(); JsonNode operationNode = methodEntry.getValue(); - // Check if the operationId matches the target operationId. if (operationNode.path("operationId").asText().equals(operationId)) { return operationNode; } diff --git a/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolTest.java b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolTest.java deleted file mode 100644 index 4fec249e2..000000000 --- a/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolTest.java +++ /dev/null @@ -1,167 +0,0 @@ -package com.google.adk.tools.applicationintegrationtoolset; - -import static com.google.common.truth.Truth.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.when; - -import com.google.adk.tools.ToolContext; -import com.google.adk.tools.applicationintegrationtoolset.ApplicationIntegrationTool.HttpExecutor; -import com.google.common.collect.ImmutableMap; -import com.google.genai.types.FunctionDeclaration; -import com.google.genai.types.Schema; -import com.google.genai.types.Type; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Map; -import java.util.Optional; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; -import org.mockito.Mock; -import org.mockito.junit.MockitoJUnit; -import org.mockito.junit.MockitoRule; - -@RunWith(JUnit4.class) -public final class ApplicationIntegrationToolTest { - - @Rule public final MockitoRule mockito = MockitoJUnit.rule(); - - // Mocks for injected dependencies - @Mock private HttpExecutor mockHttpExecutor; - @Mock private HttpResponse mockHttpResponse; - @Mock private ToolContext mockToolContext; - - private ApplicationIntegrationTool tool; - private static final String MOCK_ACCESS_TOKEN = "test-fake-token"; - private static final String MOCK_OPEN_API_SPEC = - "{\"openApiSpec\":\"" - + "{\\\"openapi\\\":\\\"3.0.1\\\",\\\"info\\\":{\\\"title\\\":\\\"test\\\",\\\"version\\\":\\\"4\\\"}," - + "\\\"paths\\\":{\\\"/v1/trigger/Trigger1\\\":{\\\"post\\\":{\\\"summary\\\":\\\"test" - + " summary" - + " 1\\\",\\\"operationId\\\":\\\"Trigger1\\\",\\\"requestBody\\\":{\\\"content\\\":{\\\"application/json\\\":{\\\"schema\\\":{\\\"$ref\\\":\\\"#/components/schemas/Trigger1_Request\\\"}}}}}},\\\"/v1/trigger/Trigger2\\\":{\\\"post\\\":{\\\"summary\\\":\\\"test" - + " summary 2\\\",\\\"operationId\\\":\\\"Trigger2\\\"}}}," - + "\\\"components\\\":{\\\"schemas\\\":{\\\"ItemObject\\\":{\\\"type\\\":\\\"object\\\",\\\"properties\\\":{\\\"id\\\":{\\\"type\\\":\\\"string\\\"}," - + " \\\"name\\\":{\\\"type\\\":\\\"string\\\"}}}," - + "\\\"Trigger1_Request\\\":{\\\"type\\\":\\\"OBJECT\\\",\\\"properties\\\":{" - + "\\\"line_items\\\":{\\\"type\\\":\\\"array\\\",\\\"items\\\":{\\\"$ref\\\":\\\"#/components/schemas/ItemObject\\\"}}," - + " \\\"order_id\\\":{\\\"type\\\":\\\"string\\\"}}}," - + "\\\"Trigger2_Request\\\":{\\\"type\\\":\\\"object\\\"}}}}\"}"; - - private static final String FAKE_PATH_URL = - "/v2/projects/test-project/locations/test-region/integrations/test:execute?triggerId=api_trigger/Trigger1"; - private static final String FAKE_TOOL_NAME = "Trigger1"; - - @Before - public void setUp() { - tool = - new ApplicationIntegrationTool( - MOCK_OPEN_API_SPEC, FAKE_PATH_URL, FAKE_TOOL_NAME, "A test tool", mockHttpExecutor); - } - - @Test - public void declaration_success() { - Optional declarationOpt = tool.declaration(); - assertThat(declarationOpt).isPresent(); - FunctionDeclaration declaration = declarationOpt.get(); - assertThat(declaration.name()).hasValue(FAKE_TOOL_NAME); - assertThat(declaration.description()).hasValue("test summary 1"); - - Optional paramsOpt = declaration.parameters(); - assertThat(paramsOpt).isPresent(); - Schema paramsSchema = paramsOpt.get(); - assertThat(paramsSchema.type()).hasValue(new Type("OBJECT")); - assertThat(paramsSchema.properties()).isPresent(); - - Map propsMap = paramsSchema.properties().get(); - assertThat(propsMap).containsKey("order_id"); - assertThat(propsMap).containsKey("line_items"); - - Schema lineItemsSchema = propsMap.get("line_items"); - assertThat(lineItemsSchema.type()).hasValue(new Type("ARRAY")); - assertThat(lineItemsSchema.items()).isPresent(); - - Schema itemSchema = lineItemsSchema.items().get(); - assertThat(itemSchema.type()).hasValue(new Type("OBJECT")); - assertThat(itemSchema.properties()).isPresent(); - Map itemPropsMap = itemSchema.properties().get(); - assertThat(itemPropsMap).containsKey("id"); - assertThat(itemPropsMap).containsKey("name"); - } - - @Test - public void declaration_operationNotFound_returnsEmpty() { - ApplicationIntegrationTool badTool = - new ApplicationIntegrationTool( - MOCK_OPEN_API_SPEC, - "/bad/path/triggerId=api_trigger/not-found", - "not-found", - "", - mockHttpExecutor); - - Optional declarationOpt = badTool.declaration(); - - assertThat(declarationOpt).isEmpty(); - } - - @Test - @SuppressWarnings("MockitoDoSetup") - public void runAsync_success() throws Exception { - String expectedResponse = "{\"executionId\":\"12345\"}"; - ImmutableMap inputArgs = ImmutableMap.of("username", "testuser"); - ApplicationIntegrationTool spyTool = spy(tool); - - doReturn(MOCK_ACCESS_TOKEN).when(spyTool).getAccessToken(); - when(mockHttpResponse.statusCode()).thenReturn(200); - when(mockHttpResponse.body()).thenReturn(expectedResponse); - - doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); - - spyTool - .runAsync(inputArgs, mockToolContext) - .test() - .assertNoErrors() - .assertValue(ImmutableMap.of("result", expectedResponse)); - } - - @Test - @SuppressWarnings("MockitoDoSetup") - public void runAsync_httpError_returnsErrorMap() throws Exception { - String errorResponse = "{\"error\":{\"message\":\"Permission denied.\"}}"; - ImmutableMap inputArgs = ImmutableMap.of("username", "testuser"); - ApplicationIntegrationTool spyTool = spy(tool); - doReturn(MOCK_ACCESS_TOKEN).when(spyTool).getAccessToken(); - - when(mockHttpResponse.statusCode()).thenReturn(403); - when(mockHttpResponse.body()).thenReturn(errorResponse); - - doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); - - String expectedErrorMessage = - "Error executing integration. Status: 403 , Response: " + errorResponse; - spyTool - .runAsync(inputArgs, mockToolContext) - .test() - .assertNoErrors() - .assertValue(ImmutableMap.of("error", expectedErrorMessage)); - } - - @Test - public void extractTriggerIdFromPath_success() { - String triggerId = tool.extractTriggerIdFromPath(FAKE_PATH_URL); - - assertThat(triggerId).isEqualTo(FAKE_TOOL_NAME); - } - - @Test - public void extractTriggerIdFromPath_noTrigger_returnsNull() { - String pathWithoutTrigger = "/v1/integrations/some/other/path"; - - String triggerId = tool.extractTriggerIdFromPath(pathWithoutTrigger); - - assertThat(triggerId).isNull(); - } -} diff --git a/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolsetTest.java b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolsetTest.java index f2c439186..5c07b3a80 100644 --- a/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolsetTest.java +++ b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ApplicationIntegrationToolsetTest.java @@ -4,17 +4,16 @@ import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.JsonParseException; import com.google.adk.tools.BaseTool; -import com.google.adk.tools.applicationintegrationtoolset.ApplicationIntegrationTool.HttpExecutor; +import com.google.adk.tools.applicationintegrationtoolset.IntegrationConnectorTool.HttpExecutor; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.List; -import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; @@ -27,103 +26,126 @@ public final class ApplicationIntegrationToolsetTest { @Rule public final MockitoRule mockito = MockitoJUnit.rule(); - @Mock private HttpExecutor mockHttpExecutor; // Mock our own interface - @Mock private HttpResponse mockHttpResponse; // Mock the response + @Mock private HttpExecutor mockHttpExecutor; + @Mock private HttpResponse mockHttpResponse; private static final String LOCATION = "us-central1"; private static final String PROJECT = "test-project"; - private static final String INTEGRATION = "test-integration"; - private static final ImmutableList TRIGGERS = ImmutableList.of("trigger-1"); private static final String MOCK_ACCESS_TOKEN = "test-token"; - private ApplicationIntegrationToolset toolset; - - @Before - public void setUp() { - toolset = - new ApplicationIntegrationToolset( - PROJECT, LOCATION, INTEGRATION, TRIGGERS, mockHttpExecutor); - } + private static final String CONNECTION = + "projects/test-project/locations/us-central1/connections/test-conn"; @Test @SuppressWarnings("MockitoDoSetup") - public void generateOpenApiSpec_success() throws Exception { - String mockResponseJson = "{\"openApiSpec\": \"{\\\"paths\\\":{}}\"}"; - + public void getTools_forIntegration_success() throws Exception { + ApplicationIntegrationToolset toolset = + new ApplicationIntegrationToolset( + PROJECT, + LOCATION, + "test-integration", + ImmutableList.of("api_trigger/trigger-1"), + null, + null, + null, + null, + null, + mockHttpExecutor); + + String mockOpenApiSpecJson = + "{\"openApiSpec\":" + + "\"{\\\"paths\\\":{\\\"/p1?triggerId=api_trigger/trigger-1\\\":{\\\"post\\\":{\\\"operationId\\\":\\\"trigger-1\\\"}}}," + + "\\\"components\\\":{\\\"schemas\\\":{}}}\"}"; + when(mockHttpExecutor.getToken()).thenReturn(MOCK_ACCESS_TOKEN); when(mockHttpResponse.statusCode()).thenReturn(200); - when(mockHttpResponse.body()).thenReturn(mockResponseJson); + when(mockHttpResponse.body()).thenReturn(mockOpenApiSpecJson); doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); - ApplicationIntegrationToolset spyToolset = spy(toolset); - doReturn(MOCK_ACCESS_TOKEN).when(spyToolset).getAccessToken(); - - String result = spyToolset.generateOpenApiSpec(); + List tools = toolset.getTools(); - assertThat(result).isEqualTo(mockResponseJson); + assertThat(tools).hasSize(1); + assertThat(tools.get(0).name()).isEqualTo("trigger-1"); } @Test @SuppressWarnings("MockitoDoSetup") - public void generateOpenApiSpec_error() throws Exception { - when(mockHttpResponse.statusCode()).thenReturn(404); + public void getTools_forConnection_success() throws Exception { + ApplicationIntegrationToolset toolset = + new ApplicationIntegrationToolset( + PROJECT, + LOCATION, + null, + null, + CONNECTION, + ImmutableMap.of("Issue", ImmutableList.of("GET")), + null, + "Jira", + "Tools for Jira", + mockHttpExecutor); + + String mockConnectionDetailsJson = + "{\"name\":\"" + + CONNECTION + + "\", \"serviceName\":\"jira.example.com\", \"host\":\"1.2.3.4\"}"; + String mockEntitySchemaJson = + "{\"name\": \"op1\", \"done\": true, \"response\": {\"jsonSchema\": {}, \"operations\":" + + " [\"GET\"]}}"; + when(mockHttpExecutor.getToken()).thenReturn(MOCK_ACCESS_TOKEN); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()) + .thenReturn(mockConnectionDetailsJson) + .thenReturn(mockEntitySchemaJson); doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); - ApplicationIntegrationToolset spyToolset = spy(toolset); - doReturn(MOCK_ACCESS_TOKEN).when(spyToolset).getAccessToken(); + List tools = toolset.getTools(); - Exception exception = assertThrows(Exception.class, spyToolset::generateOpenApiSpec); - assertThat(exception).hasMessageThat().isEqualTo("Error fetching OpenAPI spec. Status: " + 404); + assertThat(tools).hasSize(1); + assertThat(tools.get(0).name()).isEqualTo("Jira_get_issue"); } @Test - public void extractTriggerIdFromPath_success() { - String path = - "/v1/projects/test-project/locations/us-central1/integrations/test-integration/trigger/triggerId=api_trigger/trigger-id"; - - String triggerId = toolset.extractTriggerIdFromPath(path); - - assertThat(triggerId).isEqualTo("trigger-id"); + public void getTools_invalidArguments_throwsException() { + ApplicationIntegrationToolset toolset = + new ApplicationIntegrationToolset( + PROJECT, LOCATION, null, null, CONNECTION, null, null, null, null, mockHttpExecutor); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, toolset::getTools); + assertThat(e) + .hasMessageThat() + .contains( + "Invalid request, Either integration or (connection and" + + " (entityOperations or actions)) should be provided."); } @Test - public void extractTriggerIdFromPath_noTriggerId() { - String path = "/v1/projects/test-project/locations/us-central1/integrations/test-integration"; - - String triggerId = toolset.extractTriggerIdFromPath(path); - - assertThat(triggerId).isNull(); + public void getTools_forConnection_noEntityOperationsOrActions_throwsException() + throws Exception { + ApplicationIntegrationToolset toolset = + new ApplicationIntegrationToolset( + PROJECT, LOCATION, null, null, null, null, null, null, null, mockHttpExecutor); + + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, toolset::getTools); + assertThat(e) + .hasMessageThat() + .contains( + "Invalid request, Either integration or (connection and" + + " (entityOperations or actions)) should be provided."); } @Test public void getPathUrl_success() throws Exception { String openApiSpec = "{\"openApiSpec\": \"{\\\"paths\\\":{\\\"/path1\\\":{},\\\"/path2\\\":{}}}\"}"; - + ApplicationIntegrationToolset toolset = + new ApplicationIntegrationToolset(null, null, null, null, null, null, null, null, null); List paths = toolset.getPathUrl(openApiSpec); - - assertThat(paths).containsExactly("/path1", "/path2"); + assertThat(paths).containsExactly("/path1", "/path2").inOrder(); } @Test - public void getPathUrl_invalidJson() throws Exception { + public void getPathUrl_invalidJson_throwsException() { String openApiSpec = "{\"openApiSpec\": \"invalid json\"}"; - + ApplicationIntegrationToolset toolset = + new ApplicationIntegrationToolset(null, null, null, null, null, null, null, null, null); assertThrows(JsonParseException.class, () -> toolset.getPathUrl(openApiSpec)); } - - @Test - public void getTools_success() throws Exception { - String mockOpenApiSpec = - "{\"openApiSpec\":" - + "\"{\\\"paths\\\":{\\\"/p1/triggerId=api_trigger/trigger-1\\\":{},\\\"/p2/triggerId=api_trigger/trigger-2\\\":{}}}\"}"; - - ApplicationIntegrationToolset spyToolset = spy(toolset); - - doReturn(mockOpenApiSpec).when(spyToolset).generateOpenApiSpec(); - - List tools = spyToolset.getTools(); - - assertThat(tools).hasSize(2); - assertThat(tools.get(0).name()).isEqualTo("trigger-1"); - assertThat(tools.get(1).name()).isEqualTo("trigger-2"); - } } diff --git a/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ConnectionsClientTest.java b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ConnectionsClientTest.java new file mode 100644 index 000000000..1f09394cf --- /dev/null +++ b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/ConnectionsClientTest.java @@ -0,0 +1,395 @@ +package com.google.adk.tools.applicationintegrationtoolset; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.tools.applicationintegrationtoolset.ConnectionsClient.ActionSchema; +import com.google.adk.tools.applicationintegrationtoolset.ConnectionsClient.ConnectionDetails; +import com.google.adk.tools.applicationintegrationtoolset.ConnectionsClient.EntitySchemaAndOperations; +import com.google.adk.tools.applicationintegrationtoolset.IntegrationConnectorTool.HttpExecutor; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public class ConnectionsClientTest { + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock private HttpExecutor mockHttpExecutor; + @Mock private HttpResponse mockHttpResponse; + + private ConnectionsClient client; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String PROJECT = "test-project"; + private static final String LOCATION = "us-central1"; + private static final String CONNECTION = "test-conn"; + + @Before + public void setUp() throws IOException { + client = new ConnectionsClient(PROJECT, LOCATION, CONNECTION, mockHttpExecutor, objectMapper); + when(mockHttpExecutor.getToken()).thenReturn("fake-test-token"); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void getConnectionDetails_success_parsesResponseCorrectly() throws Exception { + String connectionName = "projects/test-project/locations/us-central1/connections/test-conn"; + String mockJsonResponse = + String.format( + "{\"name\": \"%s\", \"serviceDirectory\": \"my-service.com\"}", connectionName); + + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(mockJsonResponse); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); + + ConnectionDetails details = client.getConnectionDetails(); + + assertThat(details.name).isEqualTo(connectionName); + assertThat(details.serviceName).isEqualTo("my-service.com"); + assertThat(details.host).isEmpty(); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void getEntitySchemaAndOperations_withPolling_success() throws Exception { + String initialCallResponse = "{\"name\": \"operations/123\"}"; + String firstPollResponse = "{\"name\": \"operations/123\", \"done\": false}"; + String finalPollResponse = + "{\"name\": \"operations/123\", \"done\": true, \"response\": {\"jsonSchema\": {\"type\":" + + " \"object\"}, \"operations\": [\"GET\", \"LIST\"]}}"; + + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()) + .thenReturn(initialCallResponse) + .thenReturn(firstPollResponse) + .thenReturn(finalPollResponse); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); + + EntitySchemaAndOperations result = client.getEntitySchemaAndOperations("Issue"); + + assertThat(result.operations).containsExactly("GET", "LIST").inOrder(); + assertThat(result.schema).containsEntry("type", "object"); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void getEntitySchemaAndOperations_noOperationId_throwsIOException() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("{}"); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(), any()); + + IOException e = + assertThrows(IOException.class, () -> client.getEntitySchemaAndOperations("InvalidEntity")); + assertThat(e) + .hasMessageThat() + .isEqualTo("Failed to get operation ID for entity: InvalidEntity"); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void getActionSchema_success() throws Exception { + String initialCallResponse = "{\"name\": \"operations/456\"}"; + String finalPollResponse = + "{\"name\": \"operations/456\", \"done\": true, \"response\": {\"inputJsonSchema\":" + + " {\"type\": \"object\"}, \"outputJsonSchema\": {\"type\": \"array\"}," + + " \"description\": \"Test Description\", \"displayName\": \"Test Action\"}}"; + + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(initialCallResponse).thenReturn(finalPollResponse); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); + + ActionSchema result = client.getActionSchema("TestAction"); + + assertThat(result.inputSchema).containsEntry("type", "object"); + assertThat(result.outputSchema).containsEntry("type", "array"); + assertThat(result.description).isEqualTo("Test Description"); + assertThat(result.displayName).isEqualTo("Test Action"); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void getActionSchema_noOperationId_throwsIOException() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn("{}"); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(), any()); + + IOException e = assertThrows(IOException.class, () -> client.getActionSchema("InvalidAction")); + assertThat(e) + .hasMessageThat() + .isEqualTo("Failed to get operation ID for action: InvalidAction"); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void executeApiCall_on403_throwsSecurityException() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(403); + when(mockHttpResponse.body()).thenReturn("Permission Denied Error"); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(), any()); + + SecurityException e = + assertThrows(SecurityException.class, () -> client.getConnectionDetails()); + assertThat(e).hasMessageThat().contains("Permission error (status 403)"); + assertThat(e).hasMessageThat().contains("Permission Denied Error"); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void executeApiCall_on404_throwsIllegalArgumentException() throws Exception { + when(mockHttpResponse.statusCode()).thenReturn(404); + when(mockHttpResponse.body()).thenReturn("Not Found Error"); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(), any()); + + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> client.getConnectionDetails()); + assertThat(e).hasMessageThat().contains("Invalid request"); + assertThat(e).hasMessageThat().contains("Not Found Error"); + } + + @Test + public void convertJsonSchemaToOpenApiSchema_convertsCorrectly() { + ImmutableMap jsonSchema = + ImmutableMap.of( + "type", + "object", + "description", + "An issue object", + "properties", + ImmutableMap.of( + "id", ImmutableMap.of("type", "integer"), + "summary", ImmutableMap.of("type", ImmutableList.of("string", "null")))); + + Map openApiSchema = client.convertJsonSchemaToOpenApiSchema(jsonSchema); + + assertThat(openApiSchema.get("description")).isEqualTo("An issue object"); + assertThat(openApiSchema.get("type")).isEqualTo("object"); + + @SuppressWarnings("unchecked") + Map properties = (Map) openApiSchema.get("properties"); + assertThat(properties).isNotNull(); + + @SuppressWarnings("unchecked") + Map summaryProp = (Map) properties.get("summary"); + assertThat(summaryProp.get("nullable")).isEqualTo(true); + assertThat(summaryProp.get("type")).isEqualTo("string"); + } + + @Test + public void convertJsonSchemaToOpenApiSchema_arrayType_convertsCorrectly() { + ImmutableMap jsonSchema = + ImmutableMap.of( + "type", + "array", + "description", + "List of issues", + "items", + ImmutableMap.of("type", "string")); + + Map openApiSchema = client.convertJsonSchemaToOpenApiSchema(jsonSchema); + + assertThat(openApiSchema.get("description")).isEqualTo("List of issues"); + assertThat(openApiSchema.get("type")).isEqualTo("array"); + @SuppressWarnings("unchecked") + Map items = (Map) openApiSchema.get("items"); + assertThat(items.get("type")).isEqualTo("string"); + } + + @Test + public void convertJsonSchemaToOpenApiSchema_simpleType_convertsCorrectly() { + ImmutableMap jsonSchema = + ImmutableMap.of("type", "string", "description", "A String"); + + Map openApiSchema = client.convertJsonSchemaToOpenApiSchema(jsonSchema); + + assertThat(openApiSchema.get("type")).isEqualTo("string"); + assertThat(openApiSchema.get("description")).isEqualTo("A String"); + } + + @Test + public void convertJsonSchemaToOpenApiSchema_nullType_convertsCorrectly() { + ImmutableMap jsonSchema = ImmutableMap.of("type", ImmutableList.of("null")); + + Map openApiSchema = client.convertJsonSchemaToOpenApiSchema(jsonSchema); + + assertThat(openApiSchema.get("nullable")).isEqualTo(true); + } + + @Test + public void convertJsonSchemaToOpenApiSchema_emptyJson_returnsEmptyMap() { + ImmutableMap jsonSchema = ImmutableMap.of(); + + Map openApiSchema = client.convertJsonSchemaToOpenApiSchema(jsonSchema); + + assertThat(openApiSchema).isEmpty(); + } + + @Test + public void getConnectorBaseSpec_returnsCorrectSpec() { + ImmutableMap spec = ConnectionsClient.getConnectorBaseSpec(); + + assertThat(spec).containsKey("openapi"); + assertThat(spec.get("info")).isInstanceOf(Map.class); + + @SuppressWarnings("unchecked") + Map info = (Map) spec.get("info"); + assertThat(info.get("title")).isEqualTo("ExecuteConnection"); + assertThat(spec).containsKey("components"); + + @SuppressWarnings("unchecked") + Map components = (Map) spec.get("components"); + assertThat(components).containsKey("schemas"); + + @SuppressWarnings("unchecked") + Map schemas = (Map) components.get("schemas"); + assertThat(schemas).containsKey("operation"); + } + + @Test + public void getActionOperation_returnsCorrectOperation() { + ImmutableMap operation = + ConnectionsClient.getActionOperation( + "TestAction", + "EXECUTE_ACTION", + "TestActionDisplayName", + "test_tool", + "tool instructions"); + + assertThat(operation).containsKey("post"); + assertThat(operation.get("post")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map post = (Map) operation.get("post"); + assertThat(post.get("summary")).isEqualTo("TestActionDisplayName"); + assertThat(post.get("description")) + .isEqualTo("Use this tool to execute TestAction tool instructions"); + assertThat(post).containsKey("operationId"); + assertThat(post.get("operationId")).isEqualTo("test_tool_TestActionDisplayName"); + } + + @Test + public void getListOperation_returnsCorrectOperation() { + ImmutableMap operation = + ConnectionsClient.listOperation( + "Entity1", "{\"type\": \"object\"}", "test_tool", "tool instructions"); + + assertThat(operation).containsKey("post"); + assertThat(operation.get("post")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map post = (Map) operation.get("post"); + assertThat(post.get("summary")).isEqualTo("List Entity1"); + assertThat(post).containsKey("operationId"); + assertThat(post.get("operationId")).isEqualTo("test_tool_list_Entity1"); + } + + @Test + public void getGetOperation_returnsCorrectOperation() { + ImmutableMap operation = + ConnectionsClient.getOperation( + "Entity1", "{\"type\": \"object\"}", "test_tool", "tool instructions"); + + assertThat(operation).containsKey("post"); + assertThat(operation.get("post")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map post = (Map) operation.get("post"); + assertThat(post.get("summary")).isEqualTo("Get Entity1"); + assertThat(post).containsKey("operationId"); + assertThat(post.get("operationId")).isEqualTo("test_tool_get_Entity1"); + } + + @Test + public void getCreateOperation_returnsCorrectOperation() { + ImmutableMap operation = + ConnectionsClient.createOperation("Entity1", "test_tool", "tool instructions"); + + assertThat(operation).containsKey("post"); + assertThat(operation.get("post")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map post = (Map) operation.get("post"); + assertThat(post.get("summary")).isEqualTo("Creates a new Entity1"); + assertThat(post).containsKey("operationId"); + assertThat(post.get("operationId")).isEqualTo("test_tool_create_Entity1"); + } + + @Test + public void getUpdateOperation_returnsCorrectOperation() { + ImmutableMap operation = + ConnectionsClient.updateOperation("Entity1", "test_tool", "tool instructions"); + + assertThat(operation).containsKey("post"); + assertThat(operation.get("post")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map post = (Map) operation.get("post"); + assertThat(post.get("summary")).isEqualTo("Updates the Entity1"); + assertThat(post).containsKey("operationId"); + assertThat(post.get("operationId")).isEqualTo("test_tool_update_Entity1"); + } + + @Test + public void getDeleteOperation_returnsCorrectOperation() { + ImmutableMap operation = + ConnectionsClient.deleteOperation("Entity1", "test_tool", "tool instructions"); + + assertThat(operation).containsKey("post"); + assertThat(operation.get("post")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map post = (Map) operation.get("post"); + assertThat(post.get("summary")).isEqualTo("Delete the Entity1"); + assertThat(post).containsKey("operationId"); + assertThat(post.get("operationId")).isEqualTo("test_tool_delete_Entity1"); + } + + @Test + public void getCreateOperationRequest_returnsCorrectRequest() { + ImmutableMap schema = ConnectionsClient.createOperationRequest("Entity1"); + + assertThat(schema).containsKey("type"); + assertThat(schema.get("type")).isEqualTo("object"); + assertThat(schema).containsKey("properties"); + assertThat(schema.get("properties")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map properties = (Map) schema.get("properties"); + assertThat(properties).containsKey("connectorInputPayload"); + } + + @Test + public void getUpdateOperationRequest_returnsCorrectRequest() { + ImmutableMap schema = ConnectionsClient.updateOperationRequest("Entity1"); + + assertThat(schema).containsKey("type"); + assertThat(schema.get("type")).isEqualTo("object"); + assertThat(schema).containsKey("properties"); + assertThat(schema.get("properties")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map properties = (Map) schema.get("properties"); + assertThat(properties).containsKey("entityId"); + assertThat(properties).containsKey("filterClause"); + } + + @Test + public void getGetOperationRequestStatic_returnsCorrectRequest() { + ImmutableMap schema = ConnectionsClient.getOperationRequest(); + + assertThat(schema).containsKey("type"); + assertThat(schema.get("type")).isEqualTo("object"); + assertThat(schema).containsKey("properties"); + assertThat(schema.get("properties")).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map properties = (Map) schema.get("properties"); + assertThat(properties).containsKey("entityId"); + } +} diff --git a/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationClientTest.java b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationClientTest.java new file mode 100644 index 000000000..6753be68f --- /dev/null +++ b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationClientTest.java @@ -0,0 +1,448 @@ +package com.google.adk.tools.applicationintegrationtoolset; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.adk.tools.applicationintegrationtoolset.ConnectionsClient.EntitySchemaAndOperations; +import com.google.adk.tools.applicationintegrationtoolset.IntegrationConnectorTool.HttpExecutor; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.io.IOException; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public class IntegrationClientTest { + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock private HttpExecutor mockHttpExecutor; + @Mock private HttpResponse mockHttpResponse; + @Mock private ConnectionsClient mockConnectionsClient; // The mock we want the factory to return + + private static final String PROJECT = "test-project"; + private static final String LOCATION = "us-central1"; + private static final String INTEGRATION = "test-integration"; + private static final String CONNECTION = "test-connection"; + private static final String TOOL_NAME = "MyTool"; + private static final String TOOL_INSTRUCTIONS = "Instructions"; + + @Before + public void setUp() throws IOException { + when(mockHttpExecutor.getToken()).thenReturn("fake-test-token"); + } + + @Test + public void constructor_entityOperationsNullAndActionsNull_throwsException() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + null, + null, + mockHttpExecutor)); + + assertThat(exception) + .hasMessageThat() + .contains("No entity operations or actions provided. Please provide at least one of them."); + } + + @Test + public void constructor_entityOperationsEmpty_throwsException() { + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + ImmutableMap.of(), + null, + mockHttpExecutor)); + + assertThat(exception).hasMessageThat().contains("entityOperations map cannot be empty"); + } + + @Test + public void constructor_entityOperationsNullKey_throwsException() { + Map> invalidEntityOperations = new HashMap<>(); + invalidEntityOperations.put(null, ImmutableList.of("value1", "value2")); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + invalidEntityOperations, + null, + mockHttpExecutor)); + + assertThat(exception) + .hasMessageThat() + .contains("Enitity in entityOperations map cannot be null or empty"); + } + + @Test + public void constructor_entityOperationsEmptyKey_throwsException() { + ImmutableMap> invalidEntityOperations = + ImmutableMap.of("", ImmutableList.of("value1", "value2")); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + invalidEntityOperations, + null, + mockHttpExecutor)); + + assertThat(exception) + .hasMessageThat() + .contains("Enitity in entityOperations map cannot be null or empty"); + } + + @Test + public void constructor_entityOperationsNullListValue_throwsException() { + Map> invalidEntityOperations = new HashMap<>(); + invalidEntityOperations.put("key1", null); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + invalidEntityOperations, + null, + mockHttpExecutor)); + + assertThat(exception).hasMessageThat().contains("Operations for entity 'key1' cannot be null"); + } + + @Test + public void constructor_entityOperationsListWithNullString_throwsException() { + Map> invalidEntityOperations = new HashMap<>(); + List values = new ArrayList<>(); + values.add("value1"); + values.add(null); + invalidEntityOperations.put("key1", values); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + null, + null, + CONNECTION, + invalidEntityOperations, + null, + mockHttpExecutor)); + + assertThat(exception) + .hasMessageThat() + .contains("Operation for entity 'key1' cannot be null or empty"); + } + + @Test + public void constructor_entityOperationsListWithEmptyString_throwsException() { + ImmutableMap> invalidEntityOperations = + ImmutableMap.of("entity1", ImmutableList.of("value1", "")); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + invalidEntityOperations, + null, + mockHttpExecutor)); + + assertThat(exception) + .hasMessageThat() + .contains("Operation for entity 'entity1' cannot be null or empty"); + } + + @Test + public void constructor_actionsEmpty_throwsException() { + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + null, + ImmutableList.of(), + mockHttpExecutor)); + + assertThat(exception).hasMessageThat().contains("Actions list cannot be empty"); + } + + @Test + public void constructor_actionsListWithNull_throwsException() { + List invalidActions = new ArrayList<>(); + invalidActions.add("action1"); + invalidActions.add(null); + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + null, + invalidActions, + mockHttpExecutor)); + + assertThat(exception).hasMessageThat().contains("Actions list cannot contain null values"); + } + + @Test + public void constructor_actionsListWithEmptyString_throwsException() { + + IllegalArgumentException exception = + assertThrows( + IllegalArgumentException.class, + () -> + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + null, + ImmutableList.of("action1", ""), + mockHttpExecutor)); + + assertThat(exception).hasMessageThat().contains("Actions list cannot contain empty strings"); + } + + @Test + public void constructor_validActions_success() { + ImmutableList validActions = ImmutableList.of("action1", "action2"); + + var unused = + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + null, + validActions, + mockHttpExecutor); + } + + @Test + public void constructor_validEntityOperations_success() { + ImmutableMap> validEntityOperations = + ImmutableMap.of("key1", ImmutableList.of("value1", "value2")); + + var unused = + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + CONNECTION, + validEntityOperations, + null, + mockHttpExecutor); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void generateOpenApiSpec_success() throws Exception { + IntegrationClient client = + new IntegrationClient( + PROJECT, + LOCATION, + INTEGRATION, + ImmutableList.of("trigger1"), + null, + null, + null, + mockHttpExecutor); + String mockResponse = "{\"openApiSpec\":\"{}\"}"; + + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(mockResponse); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); + + String result = client.generateOpenApiSpec(); + + assertThat(result).isEqualTo(mockResponse); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void generateOpenApiSpec_httpError_throwsException() throws Exception { + IntegrationClient client = + new IntegrationClient( + PROJECT, LOCATION, INTEGRATION, null, null, null, null, mockHttpExecutor); + when(mockHttpResponse.statusCode()).thenReturn(404); + when(mockHttpResponse.body()).thenReturn("Not Found"); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); + + Exception exception = assertThrows(Exception.class, client::generateOpenApiSpec); + assertThat(exception).hasMessageThat().contains("Error fetching OpenAPI spec. Status: 404"); + } + + @Test + public void getOpenApiSpecForConnection_success() throws Exception { + IntegrationClient realClient = + new IntegrationClient( + PROJECT, + LOCATION, + null, + null, + CONNECTION, + ImmutableMap.of("Issue", ImmutableList.of("GET")), + null, + mockHttpExecutor); + + IntegrationClient spiedClient = spy(realClient); + + doReturn(mockConnectionsClient).when(spiedClient).createConnectionsClient(); + EntitySchemaAndOperations fakeSchemaData = new EntitySchemaAndOperations(); + fakeSchemaData.schema = ImmutableMap.of("type", "object"); + fakeSchemaData.operations = ImmutableList.of("GET"); + when(mockConnectionsClient.getEntitySchemaAndOperations("Issue")).thenReturn(fakeSchemaData); + + ObjectNode spec = spiedClient.getOpenApiSpecForConnection("MyTool", "Instructions"); + + verify(mockConnectionsClient).getEntitySchemaAndOperations("Issue"); + assertThat(spec.at("/paths").isObject()).isTrue(); + assertThat(spec.at("/paths").size()).isEqualTo(1); + assertThat(spec.at("/components/schemas/get_issue_Request").isObject()).isTrue(); + } + + @Test + public void getOperationIdFromPathUrl_success() throws Exception { + IntegrationClient client = + new IntegrationClient(null, null, null, null, null, null, null, null); + String openApiSpec = + "{\"openApiSpec\":" + + "\"{\\\"paths\\\":{\\\"/my/path\\\":{\\\"post\\\":{\\\"operationId\\\":\\\"my-op-id\\\"}}}}\"}"; + + String opId = client.getOperationIdFromPathUrl(openApiSpec, "/my/path"); + + assertThat(opId).isEqualTo("my-op-id"); + } + + @Test + public void getOperationIdFromPathUrl_pathNotFound_throwsException() { + IntegrationClient client = + new IntegrationClient(null, null, null, null, null, null, null, null); + String openApiSpec = + "{\"openApiSpec\":" + + "\"{\\\"paths\\\":{\\\"/my/path\\\":{\\\"post\\\":{\\\"operationId\\\":\\\"my-op-id\\\"}}}}\"}"; + + Exception e = + assertThrows( + Exception.class, () -> client.getOperationIdFromPathUrl(openApiSpec, "/not/found")); + assertThat(e).hasMessageThat().isEqualTo("Could not find operationId for pathUrl: /not/found"); + } + + @Test + public void getOperationIdFromPathUrl_invalidOpenApiSpec_throwsException() { + IntegrationClient client = + new IntegrationClient(null, null, null, null, null, null, null, null); + String openApiSpec = "{\"invalidKey\":\"value\"}"; + + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> client.getOperationIdFromPathUrl(openApiSpec, "/my/path")); + assertThat(e).hasMessageThat().contains("Failed to get OpenApiSpec"); + } + + @Test + public void getOpenApiSpecForConnection_connectionsClientThrowsException_throwsException() + throws Exception { + IntegrationClient client = + new IntegrationClient( + PROJECT, + LOCATION, + null, + null, + CONNECTION, + ImmutableMap.of("Issue", ImmutableList.of("GET")), + null, + mockHttpExecutor); + + IntegrationClient spyClient = spy(client); + doReturn(mockConnectionsClient).when(spyClient).createConnectionsClient(); + when(mockConnectionsClient.getEntitySchemaAndOperations(eq("Issue"))) + .thenThrow(new InterruptedException("Error getting schema")); + + IOException exception = + assertThrows( + IOException.class, + () -> spyClient.getOpenApiSpecForConnection(TOOL_NAME, TOOL_INSTRUCTIONS)); + + assertThat(exception) + .hasMessageThat() + .contains("Operation was interrupted while getting entity schema"); + assertThat(exception).hasCauseThat().isInstanceOf(InterruptedException.class); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } +} diff --git a/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationConnectorToolTest.java b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationConnectorToolTest.java new file mode 100644 index 000000000..5fadb3d92 --- /dev/null +++ b/core/src/test/java/com/google/adk/tools/applicationintegrationtoolset/IntegrationConnectorToolTest.java @@ -0,0 +1,292 @@ +package com.google.adk.tools.applicationintegrationtoolset; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import com.google.adk.tools.ToolContext; +import com.google.adk.tools.applicationintegrationtoolset.IntegrationConnectorTool.HttpExecutor; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.genai.types.FunctionDeclaration; +import com.google.genai.types.Schema; +import com.google.genai.types.Type; +import io.reactivex.rxjava3.observers.TestObserver; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +@RunWith(JUnit4.class) +public final class IntegrationConnectorToolTest { + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + + @Mock private HttpExecutor mockHttpExecutor; + @Mock private HttpResponse mockHttpResponse; + @Mock private ToolContext mockToolContext; + + private IntegrationConnectorTool integrationTool; + private IntegrationConnectorTool connectorTool; + private IntegrationConnectorTool connectorToolWithAction; + private static final String MOCK_ACCESS_TOKEN = "test-token"; + private static final String FAKE_PATH_URL = + "/v2/projects/test-project/locations/test-region/integrations/test:execute?triggerId=api_trigger/Trigger1"; + private static final String FAKE_TOOL_NAME = "Trigger1"; + + private static final String MOCK_INTEGRATION_OPEN_API_SPEC = + "{\"openApiSpec\":\"" + + "{\\\"openapi\\\":\\\"3.0.1\\\",\\\"info\\\":{\\\"title\\\":\\\"test\\\",\\\"version\\\":\\\"4\\\"}," + + "\\\"paths\\\":{\\\"/v2/projects/test-project/locations/test-region/integrations/test:execute?triggerId=api_trigger/Trigger1\\\":{\\\"post\\\":{\\\"summary\\\":\\\"test" + + " summary" + + " 1\\\",\\\"operationId\\\":\\\"Trigger1\\\",\\\"requestBody\\\":{\\\"content\\\":{\\\"application/json\\\":{\\\"schema\\\":{\\\"$ref\\\":\\\"#/components/schemas/Trigger1_Request\\\"}}}}}},\\\"/v1/trigger/Trigger2\\\":{\\\"post\\\":{\\\"summary\\\":\\\"test" + + " summary 2\\\",\\\"operationId\\\":\\\"Trigger2\\\"}}}," + + "\\\"components\\\":{\\\"schemas\\\":{\\\"ItemObject\\\":{\\\"type\\\":\\\"object\\\",\\\"properties\\\":{\\\"id\\\":{\\\"type\\\":\\\"string\\\"}," + + " \\\"name\\\":{\\\"type\\\":\\\"string\\\"}}}," + + "\\\"Trigger1_Request\\\":{\\\"type\\\":\\\"OBJECT\\\",\\\"properties\\\":{" + + "\\\"line_items\\\":{\\\"type\\\":\\\"array\\\",\\\"items\\\":{\\\"$ref\\\":\\\"#/components/schemas/ItemObject\\\"}}," + + " \\\"order_id\\\":{\\\"type\\\":\\\"string\\\"}}, \\\"required\\\":" + + " [\\\"order_id\\\"]}}}}\"}"; + + public static final String LIST_ENTITIES_SPEC = + "{\"openApiSpec\": \"{\\\"openapi\\\":\\\"3.0.1\\\",\\\"info\\\":{\\\"title\\\":\\\"Connector" + + " for Listing" + + " Issues\\\"},\\\"paths\\\":{\\\"/v2/projects/test-project/locations/test-region/integrations/ExecuteConnection:execute\\\":{\\\"post\\\":{\\\"summary\\\":\\\"List" + + " Issue entities from the" + + " connection\\\",\\\"operationId\\\":\\\"list_issues\\\",\\\"x-entity\\\":\\\"Issue\\\",\\\"x-operation\\\":\\\"LIST_ENTITIES\\\",\\\"requestBody\\\":{\\\"required\\\":true,\\\"content\\\":{\\\"application/json\\\":{\\\"schema\\\":{\\\"$ref\\\":\\\"#/components/schemas/ListRequest\\\"}}}}}}},\\\"components\\\":{\\\"schemas\\\":{\\\"ListRequest\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"connectionName\\\",\\\"entity\\\",\\\"operation\\\"],\\\"properties\\\":{\\\"connectionName\\\":{\\\"type\\\":\\\"string\\\"},\\\"serviceName\\\":{\\\"type\\\":\\\"string\\\"},\\\"host\\\":{\\\"type\\\":\\\"string\\\"},\\\"entity\\\":{\\\"type\\\":\\\"string\\\"},\\\"operation\\\":{\\\"type\\\":\\\"string\\\"},\\\"pageSize\\\":{\\\"type\\\":\\\"integer\\\"}}}}}}\"}"; + + public static final String EXECUTE_ACTION_SPEC = + "{\"openApiSpec\": \"{\\\"openapi\\\":\\\"3.0.1\\\",\\\"info\\\":{\\\"title\\\":\\\"Connector" + + " for Executing" + + " Action\\\"},\\\"paths\\\":{\\\"/v2/projects/test-project/locations/test-region/integrations/ExecuteConnection:execute\\\":{\\\"post\\\":{\\\"summary\\\":\\\"Execute" + + " a custom" + + " action\\\",\\\"operationId\\\":\\\"execute_custom_action\\\",\\\"x-action\\\":\\\"CUSTOM_ACTION\\\",\\\"requestBody\\\":{\\\"required\\\":true,\\\"content\\\":{\\\"application/json\\\":{\\\"schema\\\":{\\\"$ref\\\":\\\"#/components/schemas/ActionRequest\\\"}}}}}}},\\\"components\\\":{\\\"schemas\\\":{\\\"ActionRequest\\\":{\\\"type\\\":\\\"object\\\",\\\"required\\\":[\\\"connectionName\\\",\\\"action\\\",\\\"payload\\\"],\\\"properties\\\":{\\\"connectionName\\\":{\\\"type\\\":\\\"string\\\"},\\\"action\\\":{\\\"type\\\":\\\"string\\\"},\\\"payload\\\":{\\\"type\\\":\\\"string\\\"}}}}}}\"}"; + + @Before + public void setUp() { + integrationTool = + new IntegrationConnectorTool( + MOCK_INTEGRATION_OPEN_API_SPEC, + FAKE_PATH_URL, + FAKE_TOOL_NAME, + "A test tool", + null, + null, + null, + mockHttpExecutor); + + connectorTool = + new IntegrationConnectorTool( + LIST_ENTITIES_SPEC, + "/v2/projects/test-project/locations/test-region/integrations/ExecuteConnection:execute", + "list_issues", + "A test tool for listing entities", + "test-connection", + "test-service", + "test-host", + mockHttpExecutor); + + connectorToolWithAction = + new IntegrationConnectorTool( + EXECUTE_ACTION_SPEC, + "/v2/projects/test-project/locations/test-region/integrations/ExecuteConnection:execute", + "execute_custom_action", + "A test tool for executing an action", + "test-connection-action", + "test-service-action", + "test-host-action", + mockHttpExecutor); + } + + @Test + public void integrationTool_declaration_success() { + Optional declarationOpt = integrationTool.declaration(); + assertThat(declarationOpt).isPresent(); + FunctionDeclaration declaration = declarationOpt.get(); + + assertThat(declaration.name()).hasValue(FAKE_TOOL_NAME); + assertThat(declaration.description()).hasValue("test summary 1"); + Optional paramsOpt = declaration.parameters(); + assertThat(paramsOpt).isPresent(); + Schema paramsSchema = paramsOpt.get(); + assertThat(paramsSchema.type()).hasValue(new Type("OBJECT")); + assertThat(paramsSchema.properties()).isPresent(); + Map propsMap = paramsSchema.properties().get(); + assertThat(propsMap).containsKey("order_id"); + assertThat(propsMap).containsKey("line_items"); + assertThat(paramsSchema.required()).hasValue(ImmutableList.of("order_id")); + Schema lineItemsSchema = propsMap.get("line_items"); + assertThat(lineItemsSchema.type()).hasValue(new Type("ARRAY")); + assertThat(lineItemsSchema.items()).isPresent(); + Schema itemSchema = lineItemsSchema.items().get(); + assertThat(itemSchema.type()).hasValue(new Type("OBJECT")); + assertThat(itemSchema.properties()).isPresent(); + Map itemPropsMap = itemSchema.properties().get(); + assertThat(itemPropsMap).containsKey("id"); + assertThat(itemPropsMap).containsKey("name"); + } + + @Test + public void declaration_removesExcludedAndOptionalFields_fromSchema() { + Optional declarationOpt = connectorTool.declaration(); + assertThat(declarationOpt).isPresent(); + + Schema paramsSchema = declarationOpt.get().parameters().get(); + + assertThat(paramsSchema.type()).hasValue(new Type("OBJECT")); + Map propsMap = paramsSchema.properties().get(); + assertThat(propsMap).doesNotContainKey("connectionName"); + assertThat(propsMap).doesNotContainKey("serviceName"); + assertThat(propsMap).doesNotContainKey("host"); + assertThat(propsMap).doesNotContainKey("entity"); + assertThat(propsMap).doesNotContainKey("operation"); + assertThat(propsMap).doesNotContainKey("action"); + assertThat(propsMap).containsKey("pageSize"); + assertThat(paramsSchema.required().get()).isEmpty(); + } + + @Test + public void integrationTool_declaration_operationNotFound_returnsEmpty() { + IntegrationConnectorTool badTool = + new IntegrationConnectorTool( + MOCK_INTEGRATION_OPEN_API_SPEC, + "/bad/path/triggerId=api_trigger/not-found", + "not-found", + "", + null, + null, + null, + mockHttpExecutor); + + Optional declarationOpt = badTool.declaration(); + + assertThat(declarationOpt).isEmpty(); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void integrationTool_runAsync_success() throws Exception { + String expectedResponse = "{\"executionId\":\"12345\"}"; + Map inputArgs = new HashMap<>(ImmutableMap.of("username", "testuser")); + IntegrationConnectorTool spyTool = spy(integrationTool); + + when(mockHttpExecutor.getToken()).thenReturn(MOCK_ACCESS_TOKEN); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(expectedResponse); + + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); + + spyTool + .runAsync(inputArgs, mockToolContext) + .test() + .assertNoErrors() + .assertValue(ImmutableMap.of("result", expectedResponse)); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void connectorTool_runAsync_success() throws Exception { + String expectedResponse = "{\"connectorOutputPayload\":[\"issue1\"]}"; + IntegrationConnectorTool spyTool = spy(connectorTool); + var unused = spyTool.declaration(); + + Map inputArgs = new HashMap<>(); + + when(mockHttpExecutor.getToken()).thenReturn(MOCK_ACCESS_TOKEN); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(expectedResponse); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(), any()); + + TestObserver> testObserver = + spyTool.runAsync(inputArgs, mockToolContext).test(); + testObserver.assertNoErrors().assertValue(ImmutableMap.of("result", expectedResponse)); + + assertThat(inputArgs).containsEntry("connectionName", "test-connection"); + assertThat(inputArgs).containsEntry("serviceName", "test-service"); + assertThat(inputArgs).containsEntry("host", "test-host"); + assertThat(inputArgs).containsEntry("entity", "Issue"); + assertThat(inputArgs).containsEntry("operation", "LIST_ENTITIES"); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void connectorToolWithAction_runAsync_success() throws Exception { + String expectedResponse = "{\"connectorOutputPayload\":[\"issue1\"]}"; + IntegrationConnectorTool spyTool = spy(connectorToolWithAction); + var unused = spyTool.declaration(); + + Map inputArgs = new HashMap<>(); + + when(mockHttpExecutor.getToken()).thenReturn(MOCK_ACCESS_TOKEN); + when(mockHttpResponse.statusCode()).thenReturn(200); + when(mockHttpResponse.body()).thenReturn(expectedResponse); + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(), any()); + + TestObserver> testObserver = + spyTool.runAsync(inputArgs, mockToolContext).test(); + testObserver.assertNoErrors().assertValue(ImmutableMap.of("result", expectedResponse)); + + assertThat(inputArgs).containsEntry("connectionName", "test-connection-action"); + assertThat(inputArgs).containsEntry("serviceName", "test-service-action"); + assertThat(inputArgs).containsEntry("host", "test-host-action"); + assertThat(inputArgs).containsEntry("action", "CUSTOM_ACTION"); + assertThat(inputArgs).doesNotContainKey("entity"); + assertThat(inputArgs).doesNotContainKey("operation"); + } + + @Test + @SuppressWarnings("MockitoDoSetup") + public void runAsync_httpError_returnsErrorMap() throws Exception { + String errorResponse = "{\"error\":{\"message\":\"Permission denied.\"}}"; + Map inputArgs = new HashMap<>(ImmutableMap.of("username", "testuser")); + IntegrationConnectorTool spyTool = spy(integrationTool); + when(mockHttpExecutor.getToken()).thenReturn(MOCK_ACCESS_TOKEN); + + when(mockHttpResponse.statusCode()).thenReturn(403); + when(mockHttpResponse.body()).thenReturn(errorResponse); + + doReturn(mockHttpResponse).when(mockHttpExecutor).send(any(HttpRequest.class), any()); + + String expectedErrorMessage = + "Error executing integration. Status: 403 , Response: " + errorResponse; + spyTool + .runAsync(inputArgs, mockToolContext) + .test() + .assertNoErrors() + .assertValue(ImmutableMap.of("error", expectedErrorMessage)); + } + + @Test + public void getOperationIdFromPathUrl_success() throws Exception { + String triggerId = + integrationTool.getOperationIdFromPathUrl(MOCK_INTEGRATION_OPEN_API_SPEC, FAKE_PATH_URL); + assertThat(triggerId).isEqualTo(FAKE_TOOL_NAME); + } + + @Test + public void getOperationIdFromPathUrl_noTrigger_throwsException() { + String pathWithoutTrigger = "/v1/integrations/some/other/path"; + String expectedErrorMessage = "Could not find operationId for pathUrl: " + pathWithoutTrigger; + + Exception exception = + assertThrows( + Exception.class, + () -> + integrationTool.getOperationIdFromPathUrl( + MOCK_INTEGRATION_OPEN_API_SPEC, pathWithoutTrigger)); + + assertThat(exception).hasMessageThat().contains(expectedErrorMessage); + } +}