|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import json |
|
import os |
|
from typing import Any, Callable, Dict, List, Optional, Tuple |
|
|
|
import requests |
|
|
|
from camel.toolkits import FunctionTool, openapi_security_config |
|
from camel.types import OpenAPIName |
|
|
|
|
|
class OpenAPIToolkit: |
|
r"""A class representing a toolkit for interacting with OpenAPI APIs. |
|
|
|
This class provides methods for interacting with APIs based on OpenAPI |
|
specifications. It dynamically generates functions for each API operation |
|
defined in the OpenAPI specification, allowing users to make HTTP requests |
|
to the API endpoints. |
|
""" |
|
|
|
def parse_openapi_file( |
|
self, openapi_spec_path: str |
|
) -> Optional[Dict[str, Any]]: |
|
r"""Load and parse an OpenAPI specification file. |
|
|
|
This function utilizes the `prance.ResolvingParser` to parse and |
|
resolve the given OpenAPI specification file, returning the parsed |
|
OpenAPI specification as a dictionary. |
|
|
|
Args: |
|
openapi_spec_path (str): The file path or URL to the OpenAPI |
|
specification. |
|
|
|
Returns: |
|
Optional[Dict[str, Any]]: The parsed OpenAPI specification |
|
as a dictionary. :obj:`None` if the package is not installed. |
|
""" |
|
try: |
|
import prance |
|
except Exception: |
|
return None |
|
|
|
|
|
parser = prance.ResolvingParser( |
|
openapi_spec_path, backend="openapi-spec-validator", strict=False |
|
) |
|
openapi_spec = parser.specification |
|
version = openapi_spec.get('openapi', {}) |
|
if not version: |
|
raise ValueError( |
|
"OpenAPI version not specified in the spec. " |
|
"Only OPENAPI 3.0.x and 3.1.x are supported." |
|
) |
|
if not (version.startswith('3.0') or version.startswith('3.1')): |
|
raise ValueError( |
|
f"Unsupported OpenAPI version: {version}. " |
|
f"Only OPENAPI 3.0.x and 3.1.x are supported." |
|
) |
|
return openapi_spec |
|
|
|
def openapi_spec_to_openai_schemas( |
|
self, api_name: str, openapi_spec: Dict[str, Any] |
|
) -> List[Dict[str, Any]]: |
|
r"""Convert OpenAPI specification to OpenAI schema format. |
|
|
|
This function iterates over the paths and operations defined in an |
|
OpenAPI specification, filtering out deprecated operations. For each |
|
operation, it constructs a schema in a format suitable for OpenAI, |
|
including operation metadata such as function name, description, |
|
parameters, and request bodies. It raises a ValueError if an operation |
|
lacks a description or summary. |
|
|
|
Args: |
|
api_name (str): The name of the API, used to prefix generated |
|
function names. |
|
openapi_spec (Dict[str, Any]): The OpenAPI specification as a |
|
dictionary. |
|
|
|
Returns: |
|
List[Dict[str, Any]]: A list of dictionaries, each representing a |
|
function in the OpenAI schema format, including details about |
|
the function's name, description, and parameters. |
|
|
|
Raises: |
|
ValueError: If an operation in the OpenAPI specification |
|
does not have a description or summary. |
|
|
|
Note: |
|
This function assumes that the OpenAPI specification |
|
follows the 3.0+ format. |
|
|
|
Reference: |
|
https://swagger.io/specification/ |
|
""" |
|
result = [] |
|
|
|
for path, path_item in openapi_spec.get('paths', {}).items(): |
|
for method, op in path_item.items(): |
|
if op.get('deprecated') is True: |
|
continue |
|
|
|
|
|
|
|
function_name = f"{api_name}" |
|
operation_id = op.get('operationId') |
|
if operation_id: |
|
function_name += f"_{operation_id}" |
|
else: |
|
function_name += f"{method}{path.replace('/', '_')}" |
|
|
|
description = op.get('description') or op.get('summary') |
|
if not description: |
|
raise ValueError( |
|
f"{method} {path} Operation from {api_name} " |
|
f"does not have a description or summary." |
|
) |
|
description += " " if description[-1] != " " else "" |
|
description += f"This function is from {api_name} API. " |
|
|
|
|
|
|
|
if 'description' in openapi_spec.get('info', {}): |
|
description += f"{openapi_spec['info']['description']}" |
|
|
|
|
|
params = op.get('parameters', []) |
|
properties: Dict[str, Any] = {} |
|
required = [] |
|
|
|
for param in params: |
|
if not param.get('deprecated', False): |
|
param_name = param['name'] + '_in_' + param['in'] |
|
properties[param_name] = {} |
|
|
|
if 'description' in param: |
|
properties[param_name]['description'] = param[ |
|
'description' |
|
] |
|
|
|
if 'schema' in param: |
|
if ( |
|
properties[param_name].get('description') |
|
and 'description' in param['schema'] |
|
): |
|
param['schema'].pop('description') |
|
properties[param_name].update(param['schema']) |
|
|
|
if param.get('required'): |
|
required.append(param_name) |
|
|
|
|
|
|
|
|
|
if 'description' not in properties[param_name]: |
|
properties[param_name]['description'] = param[ |
|
'name' |
|
] |
|
|
|
if 'type' not in properties[param_name]: |
|
properties[param_name]['type'] = 'Any' |
|
|
|
|
|
if 'requestBody' in op: |
|
properties['requestBody'] = {} |
|
requestBody = op['requestBody'] |
|
if requestBody.get('required') is True: |
|
required.append('requestBody') |
|
|
|
content = requestBody.get('content', {}) |
|
json_content = content.get('application/json', {}) |
|
json_schema = json_content.get('schema', {}) |
|
if json_schema: |
|
properties['requestBody'] = json_schema |
|
if 'description' not in properties['requestBody']: |
|
properties['requestBody']['description'] = ( |
|
"The request body, with parameters specifically " |
|
"described under the `properties` key" |
|
) |
|
|
|
function = { |
|
"type": "function", |
|
"function": { |
|
"name": function_name, |
|
"description": description, |
|
"parameters": { |
|
"type": "object", |
|
"properties": properties, |
|
"required": required, |
|
}, |
|
}, |
|
} |
|
result.append(function) |
|
|
|
return result |
|
|
|
def openapi_function_decorator( |
|
self, |
|
api_name: str, |
|
base_url: str, |
|
path: str, |
|
method: str, |
|
openapi_security: List[Dict[str, Any]], |
|
sec_schemas: Dict[str, Dict[str, Any]], |
|
operation: Dict[str, Any], |
|
) -> Callable: |
|
r"""Decorate a function to make HTTP requests based on OpenAPI |
|
specification details. |
|
|
|
This decorator dynamically constructs and executes an API request based |
|
on the provided OpenAPI operation specifications, security |
|
requirements, and parameters. It supports operations secured with |
|
`apiKey` type security schemes and automatically injects the necessary |
|
API keys from environment variables. Parameters in `path`, `query`, |
|
`header`, and `cookie` are also supported. |
|
|
|
Args: |
|
api_name (str): The name of the API, used to retrieve API key names |
|
and URLs from the configuration. |
|
base_url (str): The base URL for the API. |
|
path (str): The path for the API endpoint, |
|
relative to the base URL. |
|
method (str): The HTTP method (e.g., 'get', 'post') |
|
for the request. |
|
openapi_security (List[Dict[str, Any]]): The global security |
|
definitions as specified in the OpenAPI specs. |
|
sec_schemas (Dict[str, Dict[str, Any]]): Detailed security schemes. |
|
operation (Dict[str, Any]): A dictionary containing the OpenAPI |
|
operation details, including parameters and request body |
|
definitions. |
|
|
|
Returns: |
|
Callable: A decorator that, when applied to a function, enables the |
|
function to make HTTP requests based on the provided OpenAPI |
|
operation details. |
|
|
|
Raises: |
|
TypeError: If the security requirements include unsupported types. |
|
ValueError: If required API keys are missing from environment |
|
variables or if the content type of the request body is |
|
unsupported. |
|
""" |
|
|
|
def inner_decorator(openapi_function: Callable) -> Callable: |
|
def wrapper(**kwargs): |
|
request_url = f"{base_url.rstrip('/')}/{path.lstrip('/')}" |
|
headers = {} |
|
params = {} |
|
cookies = {} |
|
|
|
|
|
|
|
sec_requirements = operation.get('security', openapi_security) |
|
avail_sec_requirement = {} |
|
|
|
|
|
for security_requirement in sec_requirements: |
|
have_unsupported_type = False |
|
for sec_scheme_name, _ in security_requirement.items(): |
|
sec_type = sec_schemas.get(sec_scheme_name).get('type') |
|
if sec_type != "apiKey": |
|
have_unsupported_type = True |
|
break |
|
if have_unsupported_type is False: |
|
avail_sec_requirement = security_requirement |
|
break |
|
|
|
if sec_requirements and not avail_sec_requirement: |
|
raise TypeError( |
|
"Only security schemas of type `apiKey` are supported." |
|
) |
|
|
|
for sec_scheme_name, _ in avail_sec_requirement.items(): |
|
try: |
|
API_KEY_NAME = openapi_security_config.get( |
|
api_name |
|
).get(sec_scheme_name) |
|
api_key_value = os.environ[API_KEY_NAME] |
|
except Exception: |
|
api_key_url = openapi_security_config.get( |
|
api_name |
|
).get('get_api_key_url') |
|
raise ValueError( |
|
f"`{API_KEY_NAME}` not found in environment " |
|
f"variables. " |
|
f"Get `{API_KEY_NAME}` here: {api_key_url}" |
|
) |
|
request_key_name = sec_schemas.get(sec_scheme_name).get( |
|
'name' |
|
) |
|
request_key_in = sec_schemas.get(sec_scheme_name).get('in') |
|
if request_key_in == 'query': |
|
params[request_key_name] = api_key_value |
|
elif request_key_in == 'header': |
|
headers[request_key_name] = api_key_value |
|
elif request_key_in == 'coolie': |
|
cookies[request_key_name] = api_key_value |
|
|
|
|
|
for param in operation.get('parameters', []): |
|
input_param_name = param['name'] + '_in_' + param['in'] |
|
|
|
if input_param_name in kwargs: |
|
if param['in'] == 'path': |
|
request_url = request_url.replace( |
|
f"{{{param['name']}}}", |
|
str(kwargs[input_param_name]), |
|
) |
|
elif param['in'] == 'query': |
|
params[param['name']] = kwargs[input_param_name] |
|
elif param['in'] == 'header': |
|
headers[param['name']] = kwargs[input_param_name] |
|
elif param['in'] == 'cookie': |
|
cookies[param['name']] = kwargs[input_param_name] |
|
|
|
if 'requestBody' in operation: |
|
request_body = kwargs.get('requestBody', {}) |
|
content_type_list = list( |
|
operation.get('requestBody', {}) |
|
.get('content', {}) |
|
.keys() |
|
) |
|
if content_type_list: |
|
content_type = content_type_list[0] |
|
headers.update({"Content-Type": content_type}) |
|
|
|
|
|
if content_type == "application/json": |
|
response = requests.request( |
|
method.upper(), |
|
request_url, |
|
params=params, |
|
headers=headers, |
|
cookies=cookies, |
|
json=request_body, |
|
) |
|
else: |
|
raise ValueError( |
|
f"Unsupported content type: {content_type}" |
|
) |
|
else: |
|
|
|
response = requests.request( |
|
method.upper(), |
|
request_url, |
|
params=params, |
|
headers=headers, |
|
cookies=cookies, |
|
) |
|
|
|
try: |
|
return response.json() |
|
except json.JSONDecodeError: |
|
raise ValueError( |
|
"Response could not be decoded as JSON. " |
|
"Please check the input parameters." |
|
) |
|
|
|
return wrapper |
|
|
|
return inner_decorator |
|
|
|
def generate_openapi_funcs( |
|
self, api_name: str, openapi_spec: Dict[str, Any] |
|
) -> List[Callable]: |
|
r"""Generates a list of Python functions based on |
|
OpenAPI specification. |
|
|
|
This function dynamically creates a list of callable functions that |
|
represent the API operations defined in an OpenAPI specification |
|
document. Each function is designed to perform an HTTP request |
|
corresponding to an API operation (e.g., GET, POST) as defined in |
|
the specification. The functions are decorated with |
|
`openapi_function_decorator`, which configures them to construct and |
|
send the HTTP requests with appropriate parameters, headers, and body |
|
content. |
|
|
|
Args: |
|
api_name (str): The name of the API, used to prefix generated |
|
function names. |
|
openapi_spec (Dict[str, Any]): The OpenAPI specification as a |
|
dictionary. |
|
|
|
Returns: |
|
List[Callable]: A list containing the generated functions. Each |
|
function, when called, will make an HTTP request according to |
|
its corresponding API operation defined in the OpenAPI |
|
specification. |
|
|
|
Raises: |
|
ValueError: If the OpenAPI specification does not contain server |
|
information, which is necessary for determining the base URL |
|
for the API requests. |
|
""" |
|
|
|
servers = openapi_spec.get('servers', []) |
|
if not servers: |
|
raise ValueError("No server information found in OpenAPI spec.") |
|
base_url = servers[0].get('url') |
|
|
|
|
|
openapi_security = openapi_spec.get('security', {}) |
|
|
|
sec_schemas = openapi_spec.get('components', {}).get( |
|
'securitySchemes', {} |
|
) |
|
functions = [] |
|
|
|
|
|
for path, methods in openapi_spec.get('paths', {}).items(): |
|
for method, operation in methods.items(): |
|
|
|
|
|
operation_id = operation.get('operationId') |
|
if operation_id: |
|
function_name = f"{api_name}_{operation_id}" |
|
else: |
|
sanitized_path = path.replace('/', '_').strip('_') |
|
function_name = f"{api_name}_{method}_{sanitized_path}" |
|
|
|
@self.openapi_function_decorator( |
|
api_name, |
|
base_url, |
|
path, |
|
method, |
|
openapi_security, |
|
sec_schemas, |
|
operation, |
|
) |
|
def openapi_function(**kwargs): |
|
pass |
|
|
|
openapi_function.__name__ = function_name |
|
|
|
functions.append(openapi_function) |
|
|
|
return functions |
|
|
|
def apinames_filepaths_to_funs_schemas( |
|
self, |
|
apinames_filepaths: List[Tuple[str, str]], |
|
) -> Tuple[List[Callable], List[Dict[str, Any]]]: |
|
r"""Combines functions and schemas from multiple OpenAPI |
|
specifications, using API names as keys. |
|
|
|
This function iterates over tuples of API names and OpenAPI spec file |
|
paths, parsing each spec to generate callable functions and schema |
|
dictionaries, all organized by API name. |
|
|
|
Args: |
|
apinames_filepaths (List[Tuple[str, str]]): A list of tuples, where |
|
each tuple consists of: |
|
- The API name (str) as the first element. |
|
- The file path (str) to the API's OpenAPI specification file as |
|
the second element. |
|
|
|
Returns: |
|
Tuple[List[Callable], List[Dict[str, Any]]]:: one of callable |
|
functions for API operations, and another of dictionaries |
|
representing the schemas from the specifications. |
|
""" |
|
combined_func_lst = [] |
|
combined_schemas_list = [] |
|
for api_name, file_path in apinames_filepaths: |
|
|
|
current_dir = os.path.dirname(__file__) |
|
file_path = os.path.join( |
|
current_dir, 'open_api_specs', f'{api_name}', 'openapi.yaml' |
|
) |
|
|
|
openapi_spec = self.parse_openapi_file(file_path) |
|
if openapi_spec is None: |
|
return [], [] |
|
|
|
|
|
openapi_functions_schemas = self.openapi_spec_to_openai_schemas( |
|
api_name, openapi_spec |
|
) |
|
combined_schemas_list.extend(openapi_functions_schemas) |
|
|
|
|
|
openapi_functions_list = self.generate_openapi_funcs( |
|
api_name, openapi_spec |
|
) |
|
combined_func_lst.extend(openapi_functions_list) |
|
|
|
return combined_func_lst, combined_schemas_list |
|
|
|
def generate_apinames_filepaths(self) -> List[Tuple[str, str]]: |
|
"""Generates a list of tuples containing API names and their |
|
corresponding file paths. |
|
|
|
This function iterates over the OpenAPIName enum, constructs the file |
|
path for each API's OpenAPI specification file, and appends a tuple of |
|
the API name and its file path to the list. The file paths are relative |
|
to the 'open_api_specs' directory located in the same directory as this |
|
script. |
|
|
|
Returns: |
|
List[Tuple[str, str]]: A list of tuples where each tuple contains |
|
two elements. The first element of each tuple is a string |
|
representing the name of an API, and the second element is a |
|
string that specifies the file path to that API's OpenAPI |
|
specification file. |
|
""" |
|
apinames_filepaths = [] |
|
current_dir = os.path.dirname(__file__) |
|
for api_name in OpenAPIName: |
|
file_path = os.path.join( |
|
current_dir, |
|
'open_api_specs', |
|
f'{api_name.value}', |
|
'openapi.yaml', |
|
) |
|
apinames_filepaths.append((api_name.value, file_path)) |
|
return apinames_filepaths |
|
|
|
def get_tools(self) -> List[FunctionTool]: |
|
r"""Returns a list of FunctionTool objects representing the |
|
functions in the toolkit. |
|
|
|
Returns: |
|
List[FunctionTool]: A list of FunctionTool objects |
|
representing the functions in the toolkit. |
|
""" |
|
apinames_filepaths = self.generate_apinames_filepaths() |
|
all_funcs_lst, all_schemas_lst = ( |
|
self.apinames_filepaths_to_funs_schemas(apinames_filepaths) |
|
) |
|
return [ |
|
FunctionTool(a_func, a_schema) |
|
for a_func, a_schema in zip(all_funcs_lst, all_schemas_lst) |
|
] |
|
|