diff --git a/haystack/components/connectors/openapi_service.py b/haystack/components/connectors/openapi_service.py index 5238722f7f..6164000343 100644 --- a/haystack/components/connectors/openapi_service.py +++ b/haystack/components/connectors/openapi_service.py @@ -3,8 +3,6 @@ # SPDX-License-Identifier: Apache-2.0 import json -from collections import defaultdict -from copy import copy from typing import Any, Dict, List, Optional, Union from haystack import component, logging @@ -13,25 +11,22 @@ logger = logging.getLogger(__name__) -with LazyImport("Run 'pip install openapi3'") as openapi_imports: - from openapi3 import OpenAPI +with LazyImport("Run 'pip install openapi-service-client'") as openapi_imports: + from openapi_service_client import ClientConfigurationBuilder, OpenAPIServiceClient + from openapi_service_client.providers import AnthropicLLMProvider, CohereLLMProvider, OpenAILLMProvider @component class OpenAPIServiceConnector: """ - A component which connects the Haystack framework to OpenAPI services. - - The `OpenAPIServiceConnector` component connects the Haystack framework to OpenAPI services, enabling it to call - operations as defined in the OpenAPI specification of the service. + The `OpenAPIServiceConnector` component connects the Haystack framework to OpenAPI services. It integrates with `ChatMessage` dataclass, where the payload in messages is used to determine the method to be - called and the parameters to be passed. The message payload should be an OpenAI JSON formatted function calling - string consisting of the method name and the parameters to be passed to the method. The method name and parameters - are then used to invoke the method on the OpenAPI service. The response from the service is returned as a - `ChatMessage`. + called and the parameters to be passed. The response from the service is returned as a `ChatMessage`. + + Function calling payloads from OpenAI, Anthropic, and Cohere LLMs are supported. - Before using this component, users usually resolve service endpoint parameters with a help of + Before using this component, users usually resolve function calling function definitions with a help of `OpenAPIServiceToFunctions` component. The example below demonstrates how to use the `OpenAPIServiceConnector` to invoke a method on a https://serper.dev/ @@ -69,11 +64,23 @@ class OpenAPIServiceConnector: """ - def __init__(self): + def __init__(self, provider_map: Optional[Dict[str, Any]] = None, default_provider: Optional[str] = None): """ Initializes the OpenAPIServiceConnector instance + + :param provider_map: A dictionary mapping provider names to their respective LLMProvider instances. The default + providers are OpenAILLMProvider, AnthropicLLMProvider, and CohereLLMProvider. """ openapi_imports.check() + self.provider_map = provider_map or { + "openai": OpenAILLMProvider(), + "anthropic": AnthropicLLMProvider(), + "cohere": CohereLLMProvider(), + } + default_provider = default_provider or "openai" + if default_provider not in self.provider_map: + raise ValueError(f"Default provider {default_provider} not found in provider map.") + self.default_provider = default_provider @component.output_types(service_response=Dict[str, Any]) def run( @@ -81,6 +88,7 @@ def run( messages: List[ChatMessage], service_openapi_spec: Dict[str, Any], service_credentials: Optional[Union[dict, str]] = None, + llm_provider: Optional[str] = None, ) -> Dict[str, List[ChatMessage]]: """ Processes a list of chat messages to invoke a method on an OpenAPI service. @@ -91,10 +99,11 @@ def run( :param messages: A list of `ChatMessage` objects containing the messages to be processed. The last message should contain the function invocation payload in OpenAI function calling format. See the example in the class docstring for the expected format. - :param service_openapi_spec: The OpenAPI JSON specification object of the service to be invoked. All the refs - should already be resolved. + :param service_openapi_spec: The OpenAPI JSON specification object of the service to be invoked. :param service_credentials: The credentials to be used for authentication with the service. Currently, only the http and apiKey OpenAPI security schemes are supported. + :param llm_provider: The name of the LLM provider that generated the function calling payload. + Default is "openai". :return: A dictionary with the following keys: - `service_response`: a list of `ChatMessage` objects, each containing the response from the service. The @@ -108,163 +117,30 @@ def run( last_message = messages[-1] if not last_message.is_from(ChatRole.ASSISTANT): raise ValueError(f"{last_message} is not from the assistant.") - - function_invocation_payloads = self._parse_message(last_message) - - # instantiate the OpenAPI service for the given specification - openapi_service = OpenAPI(service_openapi_spec) - self._authenticate_service(openapi_service, service_credentials) - - response_messages = [] - for method_invocation_descriptor in function_invocation_payloads: - service_response = self._invoke_method(openapi_service, method_invocation_descriptor) - # openapi3 parses the JSON service response into a model object, which is not our focus at the moment. - # Instead, we require direct access to the raw JSON data of the response, rather than the model objects - # provided by the openapi3 library. This approach helps us avoid issues related to (de)serialization. - # By accessing the raw JSON response through `service_response._raw_data`, we can serialize this data - # into a string. Finally, we use this string to create a ChatMessage object. - response_messages.append(ChatMessage.from_user(json.dumps(service_response._raw_data))) - - return {"service_response": response_messages} - - def _parse_message(self, message: ChatMessage) -> List[Dict[str, Any]]: - """ - Parses the message to extract the method invocation descriptor. - - :param message: ChatMessage containing the tools calls - :return: A list of function invocation payloads - :raises ValueError: If the content is not valid JSON or lacks required fields. - """ - function_payloads = [] + if not last_message.content: + raise ValueError("Function calling message content is empty.") + + default_provider = self.provider_map.get(self.default_provider, None) + llm_provider = self.provider_map.get(llm_provider or "openai", None) or default_provider + logger.debug(f"Using LLM provider: {llm_provider.__class__.__name__}") + + builder = ClientConfigurationBuilder() + config_openapi = ( + builder.with_openapi_spec(service_openapi_spec) + .with_credentials(service_credentials) + .with_provider(llm_provider) + .build() + ) + logger.debug(f"Invoking service {config_openapi.get_openapi_spec().get_name()} with {last_message.content}") + openapi_service = OpenAPIServiceClient(config_openapi) try: - tool_calls = json.loads(message.content) - except json.JSONDecodeError: - raise ValueError("Invalid JSON content, expected OpenAI tools message.", message.content) - - for tool_call in tool_calls: - # this should never happen, but just in case do a sanity check - if "type" not in tool_call: - raise ValueError("Message payload doesn't seem to be a tool invocation descriptor", message.content) - - # In OpenAPIServiceConnector we know how to handle functions tools only - if tool_call["type"] == "function": - function_call = tool_call["function"] - function_payloads.append( - {"arguments": json.loads(function_call["arguments"]), "name": function_call["name"]} - ) - return function_payloads - - def _authenticate_service(self, openapi_service: OpenAPI, credentials: Optional[Union[dict, str]] = None): - """ - Authentication with an OpenAPI service. - - Authenticates with the OpenAPI service if required, supporting both single (str) and multiple - authentication methods (dict). - - OpenAPI spec v3 supports the following security schemes: - http – for Basic, Bearer and other HTTP authentications schemes - apiKey – for API keys and cookie authentication - oauth2 – for OAuth 2 - openIdConnect – for OpenID Connect Discovery - - Currently, only the http and apiKey schemes are supported. Multiple security schemes can be defined in the - OpenAPI spec, and the credentials should be provided as a dictionary with keys matching the security scheme - names. If only one security scheme is defined, the credentials can be provided as a simple string. - - :param openapi_service: The OpenAPI service instance. - :param credentials: Credentials for authentication, which can be either a string (e.g. token) or a dictionary - with keys matching the authentication method names. - :raises ValueError: If authentication fails, is not found, or if appropriate credentials are missing. - """ - if openapi_service.raw_element.get("components", {}).get("securitySchemes"): - service_name = openapi_service.info.title - if not credentials: - raise ValueError(f"Service {service_name} requires authentication but no credentials were provided.") - - # a dictionary of security schemes defined in the OpenAPI spec - # each key is the name of the security scheme, and the value is the scheme definition - security_schemes = openapi_service.components.securitySchemes.raw_element - supported_schemes = ["http", "apiKey"] # todo: add support for oauth2 and openIdConnect - - authenticated = False - for scheme_name, scheme in security_schemes.items(): - if scheme["type"] in supported_schemes: - auth_credentials = None - if isinstance(credentials, str): - auth_credentials = credentials - elif isinstance(credentials, dict) and scheme_name in credentials: - auth_credentials = credentials[scheme_name] - if auth_credentials: - openapi_service.authenticate(scheme_name, auth_credentials) - authenticated = True - break - - raise ValueError( - f"Service {service_name} requires {scheme_name} security scheme but no " - f"credentials were provided for it. Check the service configuration and credentials." - ) - if not authenticated: - raise ValueError( - f"Service {service_name} requires authentication but no credentials were provided " - f"for it. Check the service configuration and credentials." - ) - - def _invoke_method(self, openapi_service: OpenAPI, method_invocation_descriptor: Dict[str, Any]) -> Any: - """ - Invokes the specified method on the OpenAPI service. - - The method name and arguments are passed in the method_invocation_descriptor. - - :param openapi_service: The OpenAPI service instance. - :param method_invocation_descriptor: The method name and arguments to be passed to the method. The payload - should contain the method name (key: "name") and the arguments (key: "arguments"). The name is a string, and - the arguments are a dictionary of key-value pairs. - :return: A service JSON response. - :raises RuntimeError: If the method is not found or invocation fails. - """ - name = method_invocation_descriptor.get("name") - invocation_arguments = copy(method_invocation_descriptor.get("arguments", {})) - if not name or not invocation_arguments: - raise ValueError( - f"Invalid function calling descriptor: {method_invocation_descriptor} . It should contain " - f"a method name and arguments." + payload = ( + json.loads(last_message.content) if isinstance(last_message.content, str) else last_message.content ) + service_response = openapi_service.invoke(payload) + except Exception as e: + logger.error(f"Error invoking OpenAPI endpoint. Error: {e}") + service_response = {"error": str(e)} + response_messages = [ChatMessage.from_user(json.dumps(service_response))] - # openapi3 specific method to call the operation, do we have it? - method_to_call = getattr(openapi_service, f"call_{name}", None) - if not callable(method_to_call): - raise RuntimeError(f"Operation {name} not found in OpenAPI specification {openapi_service.info.title}") - - # get the operation reference from the method_to_call - operation = method_to_call.operation.__self__ - operation_dict = operation.raw_element - - # Pack URL/query parameters under "parameters" key - method_call_params: Dict[str, Dict[str, Any]] = defaultdict(dict) - parameters = operation_dict.get("parameters", []) - request_body = operation_dict.get("requestBody", {}) - - for param in parameters: - param_name = param["name"] - param_value = invocation_arguments.get(param_name) - if param_value: - method_call_params["parameters"][param_name] = param_value - else: - if param.get("required", False): - raise ValueError(f"Missing parameter: '{param_name}' required for the '{name}' operation.") - - # Pack request body parameters under "data" key - if request_body: - schema = request_body.get("content", {}).get("application/json", {}).get("schema", {}) - required_params = schema.get("required", []) - for param_name in schema.get("properties", {}): - param_value = invocation_arguments.get(param_name) - if param_value: - method_call_params["data"][param_name] = param_value - else: - if param_name in required_params: - raise ValueError( - f"Missing requestBody parameter: '{param_name}' required for the '{name}' operation." - ) - # call the underlying service REST API with the parameters - return method_to_call(**method_call_params) + return {"service_response": response_messages} diff --git a/haystack/components/converters/openapi_functions.py b/haystack/components/converters/openapi_functions.py index acc5d2a232..943d84b27b 100644 --- a/haystack/components/converters/openapi_functions.py +++ b/haystack/components/converters/openapi_functions.py @@ -2,27 +2,24 @@ # # SPDX-License-Identifier: Apache-2.0 -import json -import os from pathlib import Path from typing import Any, Dict, List, Optional, Union -import yaml - from haystack import component, logging from haystack.dataclasses.byte_stream import ByteStream from haystack.lazy_imports import LazyImport logger = logging.getLogger(__name__) -with LazyImport("Run 'pip install jsonref'") as openapi_imports: - import jsonref +with LazyImport("Run 'pip install openapi-service-client'") as openapi_imports: + from openapi_service_client import ClientConfigurationBuilder + from openapi_service_client.providers import AnthropicLLMProvider, CohereLLMProvider, OpenAILLMProvider @component class OpenAPIServiceToFunctions: """ - Converts OpenAPI service definitions to a format suitable for OpenAI function calling. + Converts OpenAPI service schemas to a format suitable for OpenAI, Anthropic, or Cohere function calling. The definition must respect OpenAPI specification 3.0.0 or higher. It can be specified in JSON or YAML format. @@ -32,7 +29,6 @@ class OpenAPIServiceToFunctions: - requestBody and/or parameters - schema for the requestBody and/or parameters For more details on OpenAPI specification see the [official documentation](https://github.com/OAI/OpenAPI-Specification). - For more details on OpenAI function calling see the [official documentation](https://platform.openai.com/docs/guides/function-calling). Usage example: ```python @@ -46,19 +42,33 @@ class OpenAPIServiceToFunctions: MIN_REQUIRED_OPENAPI_SPEC_VERSION = 3 - def __init__(self): + def __init__(self, provider_map: Optional[Dict[str, Any]] = None, default_provider: Optional[str] = None): """ Create an OpenAPIServiceToFunctions component. + + :param provider_map: A dictionary mapping provider names to their respective LLMProvider instances. + :param default_provider: The default provider to use, defaults to "openai". """ openapi_imports.check() + self.provider_map = provider_map or { + "openai": OpenAILLMProvider(), + "anthropic": AnthropicLLMProvider(), + "cohere": CohereLLMProvider(), + } + default_provider = default_provider or "openai" + if default_provider not in self.provider_map: + raise ValueError(f"Default provider {default_provider} not found in provider map.") + self.default_provider = default_provider or "openai" @component.output_types(functions=List[Dict[str, Any]], openapi_specs=List[Dict[str, Any]]) - def run(self, sources: List[Union[str, Path, ByteStream]]) -> Dict[str, Any]: + def run(self, sources: List[Union[str, Path, ByteStream]], llm_provider: Optional[str] = None) -> Dict[str, Any]: """ - Converts OpenAPI definitions in OpenAI function calling format. + Converts OpenAPI definitions into LLM specific function calling format. :param sources: File paths or ByteStream objects of OpenAPI definitions (in JSON or YAML format). + :param llm_provider: + The LLM provider to use for the function calling definitions. Defaults to "openai". :returns: A dictionary with the following keys: @@ -72,187 +82,21 @@ def run(self, sources: List[Union[str, Path, ByteStream]]) -> Dict[str, Any]: """ all_extracted_fc_definitions: List[Dict[str, Any]] = [] all_openapi_specs = [] - for source in sources: - openapi_spec_content = None - if isinstance(source, (str, Path)): - if os.path.exists(source): - try: - with open(source, "r") as f: - openapi_spec_content = f.read() - except IOError as e: - logger.warning( - "IO error reading OpenAPI specification file: {source}. Error: {e}", source=source, e=e - ) - else: - logger.warning(f"OpenAPI specification file not found: {source}") - elif isinstance(source, ByteStream): - openapi_spec_content = source.data.decode("utf-8") - if not openapi_spec_content: - logger.warning( - "Invalid OpenAPI specification content provided: {openapi_spec_content}", - openapi_spec_content=openapi_spec_content, - ) - else: - logger.warning( - "Invalid source type {source}. Only str, Path, and ByteStream are supported.", source=type(source) - ) - continue + default_provider = self.provider_map.get(self.default_provider, "") + llm_provider = self.provider_map.get(llm_provider or "openai", None) or default_provider + if llm_provider is None: + raise ValueError(f"LLM provider {llm_provider} not found in provider map.") + logger.debug(f"Using LLM provider: {llm_provider.__class__.__name__}") - if openapi_spec_content: - try: - service_openapi_spec = self._parse_openapi_spec(openapi_spec_content) - functions: List[Dict[str, Any]] = self._openapi_to_functions(service_openapi_spec) - all_extracted_fc_definitions.extend(functions) - all_openapi_specs.append(service_openapi_spec) - except Exception as e: - logger.error( - "Error processing OpenAPI specification from source {source}: {error}", source=source, error=e - ) + builder = ClientConfigurationBuilder() + for source in sources: + source = source.to_string() if isinstance(source, ByteStream) else source + # to get tools definitions all we need is the openapi spec + config_openapi = builder.with_openapi_spec(source).with_provider(llm_provider).build() + all_extracted_fc_definitions.extend(config_openapi.get_tools_definitions()) + all_openapi_specs.append(config_openapi.get_openapi_spec().to_dict(resolve_references=True)) if not all_extracted_fc_definitions: logger.warning("No OpenAI function definitions extracted from the provided OpenAPI specification sources.") return {"functions": all_extracted_fc_definitions, "openapi_specs": all_openapi_specs} - - def _openapi_to_functions(self, service_openapi_spec: Dict[str, Any]) -> List[Dict[str, Any]]: - """ - OpenAPI to OpenAI function conversion. - - Extracts functions from the OpenAPI specification of the service and converts them into a format - suitable for OpenAI function calling. - - :param service_openapi_spec: The OpenAPI specification from which functions are to be extracted. - :type service_openapi_spec: Dict[str, Any] - :return: A list of dictionaries, each representing a function. Each dictionary includes the function's - name, description, and a schema of its parameters. - :rtype: List[Dict[str, Any]] - """ - - # Doesn't enforce rigid spec validation because that would require a lot of dependencies - # We check the version and require minimal fields to be present, so we can extract functions - spec_version = service_openapi_spec.get("openapi") - if not spec_version: - raise ValueError(f"Invalid OpenAPI spec provided. Could not extract version from {service_openapi_spec}") - service_openapi_spec_version = int(spec_version.split(".")[0]) - - # Compare the versions - if service_openapi_spec_version < OpenAPIServiceToFunctions.MIN_REQUIRED_OPENAPI_SPEC_VERSION: - raise ValueError( - f"Invalid OpenAPI spec version {service_openapi_spec_version}. Must be " - f"at least {OpenAPIServiceToFunctions.MIN_REQUIRED_OPENAPI_SPEC_VERSION}." - ) - - functions: List[Dict[str, Any]] = [] - for paths in service_openapi_spec["paths"].values(): - for path_spec in paths.values(): - function_dict = self._parse_endpoint_spec(path_spec) - if function_dict: - functions.append(function_dict) - return functions - - def _parse_endpoint_spec(self, resolved_spec: Dict[str, Any]) -> Optional[Dict[str, Any]]: - if not isinstance(resolved_spec, dict): - logger.warning("Invalid OpenAPI spec format provided. Could not extract function.") - return {} - - function_name = resolved_spec.get("operationId") - description = resolved_spec.get("description") or resolved_spec.get("summary", "") - - schema: Dict[str, Any] = {"type": "object", "properties": {}} - - # requestBody section - req_body_schema = ( - resolved_spec.get("requestBody", {}).get("content", {}).get("application/json", {}).get("schema", {}) - ) - if "properties" in req_body_schema: - for prop_name, prop_schema in req_body_schema["properties"].items(): - schema["properties"][prop_name] = self._parse_property_attributes(prop_schema) - - if "required" in req_body_schema: - schema.setdefault("required", []).extend(req_body_schema["required"]) - - # parameters section - for param in resolved_spec.get("parameters", []): - if "schema" in param: - schema_dict = self._parse_property_attributes(param["schema"]) - # these attributes are not in param[schema] level but on param level - useful_attributes = ["description", "pattern", "enum"] - schema_dict.update({key: param[key] for key in useful_attributes if param.get(key)}) - schema["properties"][param["name"]] = schema_dict - if param.get("required", False): - schema.setdefault("required", []).append(param["name"]) - - if function_name and description and schema["properties"]: - return {"name": function_name, "description": description, "parameters": schema} - else: - logger.warning( - "Invalid OpenAPI spec format provided. Could not extract function from {spec}", spec=resolved_spec - ) - return {} - - def _parse_property_attributes( - self, property_schema: Dict[str, Any], include_attributes: Optional[List[str]] = None - ) -> Dict[str, Any]: - """ - Parses the attributes of a property schema. - - Recursively parses the attributes of a property schema, including nested objects and arrays, - and includes specified attributes like description, pattern, etc. - - :param property_schema: The schema of the property to parse. - :param include_attributes: The list of attributes to include in the parsed schema. - :return: The parsed schema of the property including the specified attributes. - """ - include_attributes = include_attributes or ["description", "pattern", "enum"] - - schema_type = property_schema.get("type") - - parsed_schema = {"type": schema_type} if schema_type else {} - for attr in include_attributes: - if attr in property_schema: - parsed_schema[attr] = property_schema[attr] - - if schema_type == "object": - properties = property_schema.get("properties", {}) - parsed_properties = { - prop_name: self._parse_property_attributes(prop, include_attributes) - for prop_name, prop in properties.items() - } - parsed_schema["properties"] = parsed_properties - - if "required" in property_schema: - parsed_schema["required"] = property_schema["required"] - - elif schema_type == "array": - items = property_schema.get("items", {}) - parsed_schema["items"] = self._parse_property_attributes(items, include_attributes) - - return parsed_schema - - def _parse_openapi_spec(self, content: str) -> Dict[str, Any]: - """ - Parses OpenAPI specification content, supporting both JSON and YAML formats. - - :param content: The content of the OpenAPI specification. - :return: The parsed OpenAPI specification. - """ - open_api_spec_content = None - try: - open_api_spec_content = json.loads(content) - return jsonref.replace_refs(open_api_spec_content) - except json.JSONDecodeError as json_error: - # heuristic to confirm that the content is likely malformed JSON - if content.strip().startswith(("{", "[")): - raise json_error - - try: - open_api_spec_content = yaml.safe_load(content) - except yaml.YAMLError: - error_message = ( - "Failed to parse the OpenAPI specification. " - "The content does not appear to be valid JSON or YAML.\n\n" - ) - raise RuntimeError(error_message, content) - - # Replace references in the object with their resolved values, if any - return jsonref.replace_refs(open_api_spec_content) diff --git a/pyproject.toml b/pyproject.toml index e11e8b6942..966a663c98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,7 +120,7 @@ extra-dependencies = [ # OpenAPI "jsonref", # OpenAPIServiceConnector, OpenAPIServiceToFunctions - "openapi3", + "openapi-service-client", # Validation "jsonschema", diff --git a/releasenotes/notes/replace-openapi3-with-openapi-service-client-4145fdc44557dc3a.yaml b/releasenotes/notes/replace-openapi3-with-openapi-service-client-4145fdc44557dc3a.yaml new file mode 100644 index 0000000000..2196db7500 --- /dev/null +++ b/releasenotes/notes/replace-openapi3-with-openapi-service-client-4145fdc44557dc3a.yaml @@ -0,0 +1,4 @@ +--- +enhancements: + - | + Refactored the OpenAPIServiceConnector and OpenAPIServiceToFunctions to use `openapi-service-client` instead of `openapi3` library. diff --git a/test/components/connectors/test_openapi_service.py b/test/components/connectors/test_openapi_service.py index 82340bdd76..5c68e59633 100644 --- a/test/components/connectors/test_openapi_service.py +++ b/test/components/connectors/test_openapi_service.py @@ -2,332 +2,115 @@ # # SPDX-License-Identifier: Apache-2.0 import json -from unittest.mock import MagicMock, Mock, patch, PropertyMock +import os +from unittest.mock import patch import pytest -from openapi3 import OpenAPI -from openapi3.schemas import Model from haystack.components.connectors import OpenAPIServiceConnector from haystack.dataclasses import ChatMessage -@pytest.fixture -def openapi_service_mock(): - return MagicMock(spec=OpenAPI) - - class TestOpenAPIServiceConnector: @pytest.fixture - def connector(self): - return OpenAPIServiceConnector() - - def test_parse_message_invalid_json(self, connector): - # Test invalid JSON content - with pytest.raises(ValueError): - connector._parse_message(ChatMessage.from_assistant("invalid json")) - - def test_parse_valid_json_message(self): - connector = OpenAPIServiceConnector() - - # The content format here is OpenAI function calling descriptor - content = ( - '[{"function":{"name": "compare_branches","arguments": "{\\n \\"parameters\\": {\\n ' - ' \\"basehead\\": \\"main...openapi_container_v5\\",\\n ' - ' \\"owner\\": \\"deepset-ai\\",\\n \\"repo\\": \\"haystack\\"\\n }\\n}"}, "type": "function"}]' - ) - descriptors = connector._parse_message(ChatMessage.from_assistant(content)) - - # Assert that the descriptor contains the expected method name and arguments - assert descriptors[0]["name"] == "compare_branches" - assert descriptors[0]["arguments"]["parameters"] == { - "basehead": "main...openapi_container_v5", - "owner": "deepset-ai", - "repo": "haystack", - } - # but not the requestBody - assert "requestBody" not in descriptors[0]["arguments"] - - # The content format here is OpenAI function calling descriptor - content = '[{"function": {"name": "search","arguments": "{\\n \\"requestBody\\": {\\n \\"q\\": \\"haystack\\"\\n }\\n}"}, "type": "function"}]' - descriptors = connector._parse_message(ChatMessage.from_assistant(content)) - assert descriptors[0]["name"] == "search" - assert descriptors[0]["arguments"]["requestBody"] == {"q": "haystack"} - - # but not the parameters - assert "parameters" not in descriptors[0]["arguments"] - - def test_parse_message_missing_fields(self, connector): - # Test JSON content with missing fields - with pytest.raises(ValueError): - connector._parse_message(ChatMessage.from_assistant('[{"function": {"name": "test_method"}}]')) - - def test_authenticate_service_missing_authentication_token(self, connector, openapi_service_mock): - security_schemes_dict = { - "components": {"securitySchemes": {"apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"}}} - } - openapi_service_mock.raw_element = security_schemes_dict - - with pytest.raises(ValueError): - connector._authenticate_service(openapi_service_mock) - - def test_authenticate_service_having_authentication_token(self, connector, openapi_service_mock): - security_schemes_dict = { - "components": {"securitySchemes": {"apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"}}} - } - openapi_service_mock.raw_element = security_schemes_dict - openapi_service_mock.components.securitySchemes.raw_element = { - "apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"} - } - connector._authenticate_service(openapi_service_mock, "some_fake_token") - - def test_authenticate_service_having_authentication_dict(self, connector, openapi_service_mock): - security_schemes_dict = { - "components": {"securitySchemes": {"apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"}}} - } - openapi_service_mock.raw_element = security_schemes_dict - openapi_service_mock.components.securitySchemes.raw_element = { - "apiKey": {"in": "header", "name": "x-api-key", "type": "apiKey"} - } - connector._authenticate_service(openapi_service_mock, {"apiKey": "some_fake_token"}) - - def test_authenticate_service_having_authentication_dict_but_unsupported_auth( - self, connector, openapi_service_mock - ): - security_schemes_dict = {"components": {"securitySchemes": {"oauth2": {"type": "oauth2"}}}} - openapi_service_mock.raw_element = security_schemes_dict - openapi_service_mock.components.securitySchemes.raw_element = {"oauth2": {"type": "oauth2"}} - with pytest.raises(ValueError): - connector._authenticate_service(openapi_service_mock, {"apiKey": "some_fake_token"}) - - def test_for_internal_raw_data_field(self): - # see https://github.com/deepset-ai/haystack/pull/6772 for details - model = Model(data={}, schema={}) - assert hasattr(model, "_raw_data"), ( - "openapi3 changed. Model should have a _raw_data field, we rely on it in OpenAPIServiceConnector" - " to get the raw data from the service response" - ) - - @patch("haystack.components.connectors.openapi_service.OpenAPI") - def test_run(self, openapi_mock, test_files_path): - connector = OpenAPIServiceConnector() - spec_path = test_files_path / "json" / "github_compare_branch_openapi_spec.json" - spec = json.loads((spec_path).read_text()) - - mock_message = json.dumps( - [ - { - "id": "call_NJr1NBz2Th7iUWJpRIJZoJIA", - "function": { - "arguments": '{"basehead": "main...some_branch", "owner": "deepset-ai", "repo": "haystack"}', - "name": "compare_branches", - }, - "type": "function", - } - ] - ) - messages = [ChatMessage.from_assistant(mock_message)] - call_compare_branches = Mock(return_value=Mock(_raw_data="some_data")) - call_compare_branches.operation.__self__ = Mock() - call_compare_branches.operation.__self__.raw_element = { - "parameters": [{"name": "basehead"}, {"name": "owner"}, {"name": "repo"}] - } - mock_service = Mock( - call_compare_branches=call_compare_branches, - components=Mock(securitySchemes=Mock(raw_element={"apikey": {"type": "apiKey"}})), - ) - openapi_mock.return_value = mock_service - - connector.run(messages=messages, service_openapi_spec=spec, service_credentials="fake_key") - - openapi_mock.assert_called_once_with(spec) - mock_service.authenticate.assert_called_once_with("apikey", "fake_key") - - # verify call went through on the wire with the correct parameters - mock_service.call_compare_branches.assert_called_once_with( - parameters={"basehead": "main...some_branch", "owner": "deepset-ai", "repo": "haystack"} - ) - - @patch("haystack.components.connectors.openapi_service.OpenAPI") - def test_run_with_mix_params_request_body(self, openapi_mock, test_files_path): - connector = OpenAPIServiceConnector() - spec_path = test_files_path / "yaml" / "openapi_greeting_service.yml" - with open(spec_path, "r") as file: - spec = json.loads(file.read()) - mock_message = json.dumps( - [ - { - "id": "call_NJr1NBz2Th7iUWJpRIJZoJIA", - "function": {"arguments": '{"name": "John", "message": "Hello"}', "name": "greet"}, - "type": "function", - } - ] - ) - call_greet = Mock(return_value=Mock(_raw_data="Hello, John")) - call_greet.operation.__self__ = Mock() - call_greet.operation.__self__.raw_element = { - "parameters": [{"name": "name"}], - "requestBody": { - "content": {"application/json": {"schema": {"properties": {"message": {"type": "string"}}}}} - }, - } - - mock_service = Mock(call_greet=call_greet) - mock_service.raw_element = {} - openapi_mock.return_value = mock_service - - messages = [ChatMessage.from_assistant(mock_message)] - result = connector.run(messages=messages, service_openapi_spec=spec) - - # verify call went through on the wire - mock_service.call_greet.assert_called_once_with(parameters={"name": "John"}, data={"message": "Hello"}) - - response = json.loads(result["service_response"][0].content) - assert response == "Hello, John" - - @patch("haystack.components.connectors.openapi_service.OpenAPI") - def test_run_with_complex_types(self, openapi_mock, test_files_path): - connector = OpenAPIServiceConnector() - spec_path = test_files_path / "json" / "complex_types_openapi_service.json" - with open(spec_path, "r") as file: - spec = json.loads(file.read()) - mock_message = json.dumps( - [ - { - "id": "call_NJr1NBz2Th7iUWJpRIJZoJIA", - "function": { - "arguments": '{"transaction_amount": 150.75, "description": "Monthly subscription fee", "payment_method_id": "visa_ending_in_1234", "payer": {"name": "Alex Smith", "email": "alex.smith@example.com", "identification": {"type": "Driver\'s License", "number": "D12345678"}}}', - "name": "processPayment", - }, - "type": "function", - } - ] + def setup_mock(self): + with patch("haystack.components.connectors.openapi_service.OpenAPIServiceClient") as mock_client: + mock_client_instance = mock_client.return_value + mock_client_instance.invoke.return_value = {"service_response": "Yes, he was fired and rehired"} + yield mock_client_instance + + def test_init(self): + service_connector = OpenAPIServiceConnector() + assert service_connector is not None + assert service_connector.provider_map is not None + assert service_connector.default_provider == "openai" + + def test_init_with_anthropic_provider(self): + service_connector = OpenAPIServiceConnector(default_provider="anthropic") + assert service_connector is not None + assert service_connector.provider_map is not None + assert service_connector.default_provider == "anthropic" + + def test_run_with_mock(self, setup_mock, test_files_path): + fc_payload = [ + { + "function": {"arguments": '{"q": "Why was Sam Altman ousted from OpenAI?"}', "name": "search"}, + "id": "call_PmEBYvZ7mGrQP5PUASA5m9wO", + "type": "function", + } + ] + with open(os.path.join(test_files_path, "json/serperdev_openapi_spec.json"), "r") as file: + serperdev_openapi_spec = json.load(file) + + service_connector = OpenAPIServiceConnector() + result = service_connector.run( + messages=[ChatMessage.from_assistant(json.dumps(fc_payload))], + service_openapi_spec=serperdev_openapi_spec, + service_credentials="fake_api_key", ) - call_processPayment = Mock(return_value=Mock(_raw_data={"result": "accepted"})) - call_processPayment.operation.__self__ = Mock() - call_processPayment.operation.__self__.raw_element = { - "requestBody": { - "content": { - "application/json": { - "schema": { - "properties": { - "transaction_amount": {"type": "number", "example": 150.75}, - "description": {"type": "string", "example": "Monthly subscription fee"}, - "payment_method_id": {"type": "string", "example": "visa_ending_in_1234"}, - "payer": { - "type": "object", - "properties": { - "name": {"type": "string", "example": "Alex Smith"}, - "email": {"type": "string", "example": "alex.smith@example.com"}, - "identification": { - "type": "object", - "properties": { - "type": {"type": "string", "example": "Driver's License"}, - "number": {"type": "string", "example": "D12345678"}, - }, - "required": ["type", "number"], - }, - }, - "required": ["name", "email", "identification"], - }, - }, - "required": ["transaction_amount", "description", "payment_method_id", "payer"], - } - } - } + assert "service_response" in result + assert len(result["service_response"]) == 1 + assert isinstance(result["service_response"][0], ChatMessage) + response_content = json.loads(result["service_response"][0].content) + assert response_content == {"service_response": "Yes, he was fired and rehired"} + + # verify invocation payload + setup_mock.invoke.assert_called_once() + invocation_payload = [ + { + "function": {"arguments": '{"q": "Why was Sam Altman ousted from OpenAI?"}', "name": "search"}, + "id": "call_PmEBYvZ7mGrQP5PUASA5m9wO", + "type": "function", } - } - mock_service = Mock(call_processPayment=call_processPayment) - mock_service.raw_element = {} - openapi_mock.return_value = mock_service - - messages = [ChatMessage.from_assistant(mock_message)] - result = connector.run(messages=messages, service_openapi_spec=spec) - - # verify call went through on the wire - mock_service.call_processPayment.assert_called_once_with( - data={ - "transaction_amount": 150.75, - "description": "Monthly subscription fee", - "payment_method_id": "visa_ending_in_1234", - "payer": { - "name": "Alex Smith", - "email": "alex.smith@example.com", - "identification": {"type": "Driver's License", "number": "D12345678"}, - }, + ] + setup_mock.invoke.assert_called_with(invocation_payload) + + @pytest.mark.integration + @pytest.mark.skipif("SERPERDEV_API_KEY" not in os.environ, reason="SerperDev API key is not available") + def test_run(self, test_files_path): + fc_payload = [ + { + "function": {"arguments": '{"q": "Why was Sam Altman ousted from OpenAI?"}', "name": "search"}, + "id": "call_PmEBYvZ7mGrQP5PUASA5m9wO", + "type": "function", } - ) + ] - response = json.loads(result["service_response"][0].content) - assert response == {"result": "accepted"} + with open(os.path.join(test_files_path, "json/serperdev_openapi_spec.json"), "r") as file: + serperdev_openapi_spec = json.load(file) - @patch("haystack.components.connectors.openapi_service.OpenAPI") - def test_run_with_request_params_missing_in_invocation_args(self, openapi_mock, test_files_path): - connector = OpenAPIServiceConnector() - spec_path = test_files_path / "yaml" / "openapi_greeting_service.yml" - with open(spec_path, "r") as file: - spec = json.loads(file.read()) - mock_message = json.dumps( - [ - { - "id": "call_NJr1NBz2Th7iUWJpRIJZoJIA", - "function": {"arguments": '{"message": "Hello"}', "name": "greet"}, - "type": "function", - } - ] + service_connector = OpenAPIServiceConnector() + result = service_connector.run( + messages=[ChatMessage.from_assistant(json.dumps(fc_payload))], + service_openapi_spec=serperdev_openapi_spec, + service_credentials=os.environ["SERPERDEV_API_KEY"], ) - call_greet = Mock(return_value=Mock(_raw_data="Hello, John")) - call_greet.operation.__self__ = Mock() - call_greet.operation.__self__.raw_element = { - "parameters": [{"name": "name", "required": True}], - "requestBody": { - "content": {"application/json": {"schema": {"properties": {"message": {"type": "string"}}}}} - }, - } - - mock_service = Mock(call_greet=call_greet) - mock_service.raw_element = {} - openapi_mock.return_value = mock_service + assert "service_response" in result + assert len(result["service_response"]) == 1 + assert isinstance(result["service_response"][0], ChatMessage) + response_text = result["service_response"][0].content + assert "Sam" in response_text or "Altman" in response_text + + @pytest.mark.integration + def test_run_no_credentials(self, test_files_path): + fc_payload = [ + { + "function": {"arguments": '{"q": "Why was Sam Altman ousted from OpenAI?"}', "name": "search"}, + "id": "call_PmEBYvZ7mGrQP5PUASA5m9wO", + "type": "function", + } + ] - messages = [ChatMessage.from_assistant(mock_message)] - with pytest.raises(ValueError, match="Missing parameter: 'name' required for the 'greet' operation."): - connector.run(messages=messages, service_openapi_spec=spec) + with open(os.path.join(test_files_path, "json/serperdev_openapi_spec.json"), "r") as file: + serperdev_openapi_spec = json.load(file) - @patch("haystack.components.connectors.openapi_service.OpenAPI") - def test_run_with_body_properties_missing_in_invocation_args(self, openapi_mock, test_files_path): - connector = OpenAPIServiceConnector() - spec_path = test_files_path / "yaml" / "openapi_greeting_service.yml" - with open(spec_path, "r") as file: - spec = json.loads(file.read()) - mock_message = json.dumps( - [ - { - "id": "call_NJr1NBz2Th7iUWJpRIJZoJIA", - "function": {"arguments": '{"name": "John"}', "name": "greet"}, - "type": "function", - } - ] + service_connector = OpenAPIServiceConnector() + result = service_connector.run( + messages=[ChatMessage.from_assistant(json.dumps(fc_payload))], service_openapi_spec=serperdev_openapi_spec ) - call_greet = Mock(return_value=Mock(_raw_data="Hello, John")) - call_greet.operation.__self__ = Mock() - call_greet.operation.__self__.raw_element = { - "parameters": [{"name": "name"}], - "requestBody": { - "content": { - "application/json": { - "schema": {"properties": {"message": {"type": "string"}}, "required": ["message"]} - } - } - }, - } - - mock_service = Mock(call_greet=call_greet) - mock_service.raw_element = {} - openapi_mock.return_value = mock_service - - messages = [ChatMessage.from_assistant(mock_message)] - with pytest.raises( - ValueError, match="Missing requestBody parameter: 'message' required for the 'greet' operation." - ): - connector.run(messages=messages, service_openapi_spec=spec) + assert "service_response" in result + assert len(result["service_response"]) == 1 + assert isinstance(result["service_response"][0], ChatMessage) + response_text = result["service_response"][0].content + assert "403" in response_text diff --git a/test/components/converters/test_openapi_functions.py b/test/components/converters/test_openapi_functions.py index e154059038..f7bc117898 100644 --- a/test/components/converters/test_openapi_functions.py +++ b/test/components/converters/test_openapi_functions.py @@ -161,25 +161,14 @@ def yaml_serperdev_openapi_spec(): return serper_spec -class TestOpenAPIServiceToFunctions: - # test we can parse openapi spec given in json - def test_openapi_spec_parsing_json(self, json_serperdev_openapi_spec): - service = OpenAPIServiceToFunctions() - - serper_spec_json = service._parse_openapi_spec(json_serperdev_openapi_spec) - assert serper_spec_json["openapi"] == "3.0.0" - assert serper_spec_json["info"]["title"] == "SerperDev" - - # test we can parse openapi spec given in yaml - def test_openapi_spec_parsing_yaml(self, yaml_serperdev_openapi_spec): - service = OpenAPIServiceToFunctions() +@pytest.fixture +def fn_definition_transform(): + return lambda function_def: {"type": "function", "function": function_def} - serper_spec_yaml = service._parse_openapi_spec(yaml_serperdev_openapi_spec) - assert serper_spec_yaml["openapi"] == "3.0.0" - assert serper_spec_yaml["info"]["title"] == "SerperDev" +class TestOpenAPIServiceToFunctions: # test we can extract functions from openapi spec given - def test_run_with_bytestream_source(self, json_serperdev_openapi_spec): + def test_run_with_bytestream_source(self, json_serperdev_openapi_spec, fn_definition_transform): service = OpenAPIServiceToFunctions() spec_stream = ByteStream.from_string(json_serperdev_openapi_spec) result = service.run(sources=[spec_stream]) @@ -187,17 +176,19 @@ def test_run_with_bytestream_source(self, json_serperdev_openapi_spec): fc = result["functions"][0] # check that fc definition is as expected - assert fc == { - "name": "search", - "description": "Search the web with Google", - "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, - } + assert fc == fn_definition_transform( + { + "name": "search", + "description": "Search the web with Google", + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, + } + ) @pytest.mark.skipif( sys.platform in ["win32", "cygwin"], reason="Can't run on Windows Github CI, need access temp file but windows does not allow it", ) - def test_run_with_file_source(self, json_serperdev_openapi_spec): + def test_run_with_file_source(self, json_serperdev_openapi_spec, fn_definition_transform): # test we can extract functions from openapi spec given in file service = OpenAPIServiceToFunctions() # write the spec to NamedTemporaryFile and check that it is parsed correctly @@ -209,27 +200,21 @@ def test_run_with_file_source(self, json_serperdev_openapi_spec): fc = result["functions"][0] # check that fc definition is as expected - assert fc == { - "name": "search", - "description": "Search the web with Google", - "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, - } - - def test_run_with_invalid_file_source(self, caplog): - # test invalid source - service = OpenAPIServiceToFunctions() - result = service.run(sources=["invalid_source"]) - assert result["functions"] == [] - assert "not found" in caplog.text + assert fc == fn_definition_transform( + { + "name": "search", + "description": "Search the web with Google", + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, + } + ) def test_run_with_invalid_bytestream_source(self, caplog): # test invalid source service = OpenAPIServiceToFunctions() - result = service.run(sources=[ByteStream.from_string("")]) - assert result["functions"] == [] - assert "Invalid OpenAPI specification" in caplog.text + with pytest.raises(ValueError, match="Invalid OpenAPI specification"): + service.run(sources=[ByteStream.from_string("")]) - def test_complex_types_conversion(self, test_files_path): + def test_complex_types_conversion(self, test_files_path, fn_definition_transform): # ensure that complex types from OpenAPI spec are converted to the expected format in OpenAI function calling service = OpenAPIServiceToFunctions() result = service.run(sources=[test_files_path / "json" / "complex_types_openapi_service.json"]) @@ -237,9 +222,9 @@ def test_complex_types_conversion(self, test_files_path): with open(test_files_path / "json" / "complex_types_openai_spec.json") as openai_spec_file: desired_output = json.load(openai_spec_file) - assert result["functions"][0] == desired_output + assert result["functions"][0] == fn_definition_transform(desired_output) - def test_simple_and_complex_at_once(self, test_files_path, json_serperdev_openapi_spec): + def test_simple_and_complex_at_once(self, test_files_path, json_serperdev_openapi_spec, fn_definition_transform): # ensure multiple functions are extracted from multiple paths in OpenAPI spec service = OpenAPIServiceToFunctions() sources = [ @@ -251,9 +236,11 @@ def test_simple_and_complex_at_once(self, test_files_path, json_serperdev_openap with open(test_files_path / "json" / "complex_types_openai_spec.json") as openai_spec_file: desired_output = json.load(openai_spec_file) - assert result["functions"][0] == { - "name": "search", - "description": "Search the web with Google", - "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, - } - assert result["functions"][1] == desired_output + assert result["functions"][0] == fn_definition_transform( + { + "name": "search", + "description": "Search the web with Google", + "parameters": {"type": "object", "properties": {"q": {"type": "string"}}}, + } + ) + assert result["functions"][1] == fn_definition_transform(desired_output) diff --git a/test/test_files/json/github_compare_branch_openapi_spec.json b/test/test_files/json/github_compare_branch_openapi_spec.json deleted file mode 100644 index 8bd7c7ed80..0000000000 --- a/test/test_files/json/github_compare_branch_openapi_spec.json +++ /dev/null @@ -1,562 +0,0 @@ -{ - "openapi": "3.1.0", - "info": { - "title": "Github API", - "description": "Enables interaction with OpenAPI", - "version": "v1.0.0" - }, - "servers": [ - { - "url": "https://api.github.com" - } - ], - "paths": { - "/repos/{owner}/{repo}/compare/{basehead}": { - "get": { - "summary": "Compare two branches", - "description": "Compares two branches against one another.", - "tags": [ - "repos" - ], - "operationId": "compare_branches", - "externalDocs": { - "description": "API method documentation", - "url": "https://docs.github.com/enterprise-server@3.9/rest/commits/commits#compare-two-commits" - }, - "parameters": [ - { - "name": "basehead", - "description": "The base branch and head branch to compare. This parameter expects the format `BASE...HEAD`", - "in": "path", - "required": true, - "x-multi-segment": true, - "schema": { - "type": "string" - } - }, - { - "name": "owner", - "description": "The repository owner, usually a company or orgnization", - "in": "path", - "required": true, - "x-multi-segment": true, - "schema": { - "type": "string" - } - }, - { - "name": "repo", - "description": "The repository itself, the project", - "in": "path", - "required": true, - "x-multi-segment": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/commit-comparison" - } - } - } - } - }, - "x-github": { - "githubCloudOnly": false, - "enabledForGitHubApps": true, - "category": "commits", - "subcategory": "commits" - } - } - } - }, - "components": { - "schemas": { - "commit-comparison": { - "title": "Commit Comparison", - "description": "Commit Comparison", - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/repos/octocat/Hello-World/compare/master...topic" - }, - "html_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat/Hello-World/compare/master...topic" - }, - "permalink_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat/Hello-World/compare/octocat:bbcd538c8e72b8c175046e27cc8f907076331401...octocat:0328041d1152db8ae77652d1618a02e57f745f17" - }, - "diff_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat/Hello-World/compare/master...topic.diff" - }, - "patch_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat/Hello-World/compare/master...topic.patch" - }, - "base_commit": { - "$ref": "#/components/schemas/commit" - }, - "merge_base_commit": { - "$ref": "#/components/schemas/commit" - }, - "status": { - "type": "string", - "enum": [ - "diverged", - "ahead", - "behind", - "identical" - ], - "example": "ahead" - }, - "ahead_by": { - "type": "integer", - "example": 4 - }, - "behind_by": { - "type": "integer", - "example": 5 - }, - "total_commits": { - "type": "integer", - "example": 6 - }, - "commits": { - "type": "array", - "items": { - "$ref": "#/components/schemas/commit" - } - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/diff-entry" - } - } - }, - "required": [ - "url", - "html_url", - "permalink_url", - "diff_url", - "patch_url", - "base_commit", - "merge_base_commit", - "status", - "ahead_by", - "behind_by", - "total_commits", - "commits" - ] - }, - "nullable-git-user": { - "title": "Git User", - "description": "Metaproperties for Git author/committer information.", - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "\"Chris Wanstrath\"" - }, - "email": { - "type": "string", - "example": "\"chris@ozmm.org\"" - }, - "date": { - "type": "string", - "example": "\"2007-10-29T02:42:39.000-07:00\"" - } - }, - "nullable": true - }, - "nullable-simple-user": { - "title": "Simple User", - "description": "A GitHub user.", - "type": "object", - "properties": { - "name": { - "nullable": true, - "type": "string" - }, - "email": { - "nullable": true, - "type": "string" - }, - "login": { - "type": "string", - "example": "octocat" - }, - "id": { - "type": "integer", - "example": 1 - }, - "node_id": { - "type": "string", - "example": "MDQ6VXNlcjE=" - }, - "avatar_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/images/error/octocat_happy.gif" - }, - "gravatar_id": { - "type": "string", - "example": "41d064eb2195891e12d0413f63227ea7", - "nullable": true - }, - "url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/users/octocat" - }, - "html_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat" - }, - "followers_url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/users/octocat/followers" - }, - "following_url": { - "type": "string", - "example": "https://api.github.com/users/octocat/following{/other_user}" - }, - "gists_url": { - "type": "string", - "example": "https://api.github.com/users/octocat/gists{/gist_id}" - }, - "starred_url": { - "type": "string", - "example": "https://api.github.com/users/octocat/starred{/owner}{/repo}" - }, - "subscriptions_url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/users/octocat/subscriptions" - }, - "organizations_url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/users/octocat/orgs" - }, - "repos_url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/users/octocat/repos" - }, - "events_url": { - "type": "string", - "example": "https://api.github.com/users/octocat/events{/privacy}" - }, - "received_events_url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/users/octocat/received_events" - }, - "type": { - "type": "string", - "example": "User" - }, - "site_admin": { - "type": "boolean" - }, - "starred_at": { - "type": "string", - "example": "\"2020-07-09T00:17:55Z\"" - } - }, - "required": [ - "avatar_url", - "events_url", - "followers_url", - "following_url", - "gists_url", - "gravatar_id", - "html_url", - "id", - "node_id", - "login", - "organizations_url", - "received_events_url", - "repos_url", - "site_admin", - "starred_url", - "subscriptions_url", - "type", - "url" - ], - "nullable": true - }, - "verification": { - "title": "Verification", - "type": "object", - "properties": { - "verified": { - "type": "boolean" - }, - "reason": { - "type": "string" - }, - "payload": { - "type": "string", - "nullable": true - }, - "signature": { - "type": "string", - "nullable": true - } - }, - "required": [ - "verified", - "reason", - "payload", - "signature" - ] - }, - "diff-entry": { - "title": "Diff Entry", - "description": "Diff Entry", - "type": "object", - "properties": { - "sha": { - "type": "string", - "example": "bbcd538c8e72b8c175046e27cc8f907076331401" - }, - "filename": { - "type": "string", - "example": "file1.txt" - }, - "status": { - "type": "string", - "enum": [ - "added", - "removed", - "modified", - "renamed", - "copied", - "changed", - "unchanged" - ], - "example": "added" - }, - "additions": { - "type": "integer", - "example": 103 - }, - "deletions": { - "type": "integer", - "example": 21 - }, - "changes": { - "type": "integer", - "example": 124 - }, - "blob_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat/Hello-World/blob/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt" - }, - "raw_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat/Hello-World/raw/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt" - }, - "contents_url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/repos/octocat/Hello-World/contents/file1.txt?ref=6dcb09b5b57875f334f61aebed695e2e4193db5e" - }, - "patch": { - "type": "string", - "example": "@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test" - }, - "previous_filename": { - "type": "string", - "example": "file.txt" - } - }, - "required": [ - "additions", - "blob_url", - "changes", - "contents_url", - "deletions", - "filename", - "raw_url", - "sha", - "status" - ] - }, - "commit": { - "title": "Commit", - "description": "Commit", - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e" - }, - "sha": { - "type": "string", - "example": "6dcb09b5b57875f334f61aebed695e2e4193db5e" - }, - "node_id": { - "type": "string", - "example": "MDY6Q29tbWl0NmRjYjA5YjViNTc4NzVmMzM0ZjYxYWViZWQ2OTVlMmU0MTkzZGI1ZQ==" - }, - "html_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat/Hello-World/commit/6dcb09b5b57875f334f61aebed695e2e4193db5e" - }, - "comments_url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e/comments" - }, - "commit": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/repos/octocat/Hello-World/commits/6dcb09b5b57875f334f61aebed695e2e4193db5e" - }, - "author": { - "$ref": "#/components/schemas/nullable-git-user" - }, - "committer": { - "$ref": "#/components/schemas/nullable-git-user" - }, - "message": { - "type": "string", - "example": "Fix all the bugs" - }, - "comment_count": { - "type": "integer", - "example": 0 - }, - "tree": { - "type": "object", - "properties": { - "sha": { - "type": "string", - "example": "827efc6d56897b048c772eb4087f854f46256132" - }, - "url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/repos/octocat/Hello-World/tree/827efc6d56897b048c772eb4087f854f46256132" - } - }, - "required": [ - "sha", - "url" - ] - }, - "verification": { - "$ref": "#/components/schemas/verification" - } - }, - "required": [ - "author", - "committer", - "comment_count", - "message", - "tree", - "url" - ] - }, - "author": { - "$ref": "#/components/schemas/nullable-simple-user" - }, - "committer": { - "$ref": "#/components/schemas/nullable-simple-user" - }, - "parents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "sha": { - "type": "string", - "example": "7638417db6d59f3c431d3e1f261cc637155684cd" - }, - "url": { - "type": "string", - "format": "uri", - "example": "https://api.github.com/repos/octocat/Hello-World/commits/7638417db6d59f3c431d3e1f261cc637155684cd" - }, - "html_url": { - "type": "string", - "format": "uri", - "example": "https://github.com/octocat/Hello-World/commit/7638417db6d59f3c431d3e1f261cc637155684cd" - } - }, - "required": [ - "sha", - "url" - ] - } - }, - "stats": { - "type": "object", - "properties": { - "additions": { - "type": "integer" - }, - "deletions": { - "type": "integer" - }, - "total": { - "type": "integer" - } - } - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/diff-entry" - } - } - }, - "required": [ - "url", - "sha", - "node_id", - "html_url", - "comments_url", - "commit", - "author", - "committer", - "parents" - ] - } - }, - "securitySchemes": { - "apikey": { - "type": "apiKey", - "name": "x-api-key", - "in": "header" - } - } - } -} diff --git a/test/test_files/json/serperdev_openapi_spec.json b/test/test_files/json/serperdev_openapi_spec.json new file mode 100644 index 0000000000..123c993a1c --- /dev/null +++ b/test/test_files/json/serperdev_openapi_spec.json @@ -0,0 +1,62 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "SerperDev", + "version": "1.0.0", + "description": "API for performing search queries" + }, + "servers": [ + { + "url": "https://google.serper.dev" + } + ], + "paths": { + "/search": { + "post": { + "operationId": "search", + "description": "Search the web with Google", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "q": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "security": [ + { + "apikey": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "apikey": { + "type": "apiKey", + "name": "x-api-key", + "in": "header" + } + } + } +} diff --git a/test/test_files/yaml/openapi_greeting_service.yml b/test/test_files/yaml/openapi_greeting_service.yml deleted file mode 100644 index 6a25fc9880..0000000000 --- a/test/test_files/yaml/openapi_greeting_service.yml +++ /dev/null @@ -1,65 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Simple Greeting Service", - "version": "1.0.0", - "description": "API for performing greetings" - }, - "servers": [ - { - "url": "http://localhost:8080" - } - ], - "paths": { - "/greet/{name}": { - "post": { - "operationId": "greet", - "summary": "Greet a person with a message (Mixed params and body)", - "parameters": [ - { - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - }, - "description": "Name of the person to greet" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Custom message to send" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Greeting delivered", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "greeting": { - "type": "string" - } - } - } - } - } - } - } - } - } - } -}