Files
semantic-kernel/python/semantic_kernel/connectors/openapi/sk_openapi.py
T
Julio Carlos MenendezandEduard van Valkenburg d3dd5c8e7e Python: Uses Python standard logging (#4115)
### Motivation and Context

This replaces passing Logger instances as arguments around with the
preferred Python way of having each module define a module-wide `logger`
instance at the top of the module. Fixes #3711

### Description

Removes any `log` argument and argument documentation. Replaces usage of
those `log` arguments with the module-wide `logger` instance.

### Contribution Checklist

- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [SK Contribution
Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md)
and the [pre-submission formatting
script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts)
raises no violations
- [x] All unit tests pass, and I have added new tests where possible
- [x] I didn't break anyone 😄

---------

Co-authored-by: Eduard van Valkenburg <[email protected]>
2023-12-16 18:41:41 +00:00

320 lines
11 KiB
Python

import json
import logging
from typing import Dict, Mapping, Optional, Union
from urllib.parse import urljoin
import aiohttp
import requests
from openapi_core import Spec, unmarshal_request
from openapi_core.contrib.requests import RequestsOpenAPIRequest
from openapi_core.exceptions import OpenAPIError
from prance import ResolvingParser
from semantic_kernel import Kernel, SKContext
from semantic_kernel.connectors.ai.open_ai.const import (
USER_AGENT,
)
from semantic_kernel.connectors.telemetry import HTTP_USER_AGENT
from semantic_kernel.orchestration.sk_function_base import SKFunctionBase
from semantic_kernel.skill_definition import sk_function, sk_function_context_parameter
logger: logging.Logger = logging.getLogger(__name__)
class PreparedRestApiRequest:
def __init__(
self, method: str, url: str, params=None, headers=None, request_body=None
):
self.method = method
self.url = url
self.params = params
self.headers = headers
self.request_body = request_body
def __repr__(self):
return (
"PreparedRestApiRequest("
f"method={self.method}, "
f"url={self.url}, "
f"params={self.params}, "
f"headers={self.headers}, "
f"request_body={self.request_body})"
)
def validate_request(self, spec: Spec, **kwargs):
if kwargs.get("logger"):
logger.warning(
"The `logger` parameter is deprecated. Please use the `logging` module instead."
)
request = requests.Request(
self.method,
self.url,
params=self.params,
headers=self.headers,
json=self.request_body,
)
openapi_request = RequestsOpenAPIRequest(request=request)
try:
unmarshal_request(openapi_request, spec=spec)
return True
except OpenAPIError as e:
logger.debug(f"Error validating request: {e}", exc_info=True)
return False
class RestApiOperation:
def __init__(
self,
id: str,
method: str,
server_url: str,
path: str,
summary: Optional[str] = None,
description: Optional[str] = None,
params: Optional[Mapping[str, str]] = None,
request_body: Optional[Mapping[str, str]] = None,
):
self.id = id
self.method = method
self.server_url = server_url
self.path = path
self.summary = summary
self.description = description
self.params = params
self.request_body = request_body
"""
Fills in this RestApiOperation's parameters and payload with the provided values
:param path_params: A dictionary of path parameters
:param query_params: A dictionary of query parameters
:param headers: A dictionary of headers
:param request_body: The payload of the request
:return: A PreparedRestApiRequest object
"""
def prepare_request(
self, path_params=None, query_params=None, headers=None, request_body=None
) -> PreparedRestApiRequest:
path = self.path
if path_params:
path = path.format(**path_params)
url = urljoin(self.server_url, path)
processed_query_params, processed_headers = {}, headers
for param in self.params:
param_name = param["name"]
param_schema = param["schema"]
param_default = param_schema.get("default", None)
if param["in"] == "query":
if query_params and param_name in query_params:
processed_query_params[param_name] = query_params[param_name]
elif param["schema"] and "default" in param["schema"] is not None:
processed_query_params[param_name] = param_default
elif param["in"] == "header":
if headers and param_name in headers:
processed_headers[param_name] = headers[param_name]
elif param_default is not None:
processed_headers[param_name] = param_default
elif param["in"] == "path":
if not path_params or param_name not in path_params:
raise ValueError(
f"Required path parameter {param_name} not provided"
)
processed_payload = None
if self.request_body:
if (
request_body is None
and "required" in self.request_body
and self.request_body["required"]
):
raise ValueError("Payload is required but was not provided")
content = self.request_body["content"]
content_type = list(content.keys())[0]
processed_headers["Content-Type"] = content_type
processed_payload = request_body
processed_headers[USER_AGENT] = " ".join(
(HTTP_USER_AGENT, processed_headers.get(USER_AGENT, ""))
).rstrip()
req = PreparedRestApiRequest(
method=self.method,
url=url,
params=processed_query_params,
headers=processed_headers,
request_body=processed_payload,
)
return req
def __repr__(self):
return (
"RestApiOperation("
f"id={self.id}, "
f"method={self.method}, "
f"server_url={self.server_url}, "
f"path={self.path}, "
f"params={self.params}, "
f"request_body={self.request_body}, "
f"summary={self.summary}, "
f"description={self.description})"
)
class OpenApiParser:
def __init__(self, **kwargs):
if kwargs.get("logger"):
logger.warning(
"The `logger` parameter is deprecated. Please use the `logging` module instead."
)
"""
Import an OpenAPI file.
:param openapi_file: The path to the OpenAPI file which can be local or a URL.
:return: The parsed OpenAPI file
"""
def parse(self, openapi_document):
parser = ResolvingParser(openapi_document)
return parser.specification
"""
Creates a RestApiOperation object for each path/method combination
:param parsed_document: The parsed OpenAPI document
:return: A dictionary of RestApiOperation objects keyed by operationId
"""
def create_rest_api_operations(
self, parsed_document
) -> Dict[str, RestApiOperation]:
paths = parsed_document.get("paths", {})
request_objects = {}
for path, methods in paths.items():
for method, details in methods.items():
server_url = parsed_document.get("servers", [])
server_url = server_url[0].get("url") if server_url else "/"
request_method = method.lower()
parameters = details.get("parameters", [])
operationId = details.get("operationId", path + "_" + request_method)
summary = details.get("summary", None)
description = details.get("description", None)
rest_api_operation = RestApiOperation(
id=operationId,
method=request_method,
server_url=server_url,
path=path,
params=parameters,
request_body=details.get("requestBody", None),
summary=summary,
description=description,
)
request_objects[operationId] = rest_api_operation
return request_objects
class OpenApiRunner:
def __init__(
self,
parsed_openapi_document: Mapping[str, str],
):
self.spec = Spec.from_dict(parsed_openapi_document)
async def run_operation(
self,
operation: RestApiOperation,
path_params: Optional[Dict[str, str]] = None,
query_params: Optional[Dict[str, str]] = None,
headers: Optional[Dict[str, str]] = None,
request_body: Optional[Union[str, Dict[str, str]]] = None,
) -> aiohttp.ClientResponse:
prepared_request = operation.prepare_request(
path_params=path_params,
query_params=query_params,
headers=headers,
request_body=request_body,
)
is_valid = prepared_request.validate_request(spec=self.spec)
if not is_valid:
return None
async with aiohttp.ClientSession(raise_for_status=True) as session:
async with session.request(
prepared_request.method,
prepared_request.url,
params=prepared_request.params,
headers=prepared_request.headers,
json=prepared_request.request_body,
) as response:
return await response.text()
"""
Registers a skill with the kernel that can run OpenAPI operations.
:param kernel: The kernel to register the skill with
:param skill_name: The name of the skill
:param openapi_document: The OpenAPI document to register. Can be a filename or URL
:return: A dictionary of SKFunctions keyed by operationId
"""
def register_openapi_skill(
kernel: Kernel,
skill_name: str,
openapi_document: str,
) -> Dict[str, SKFunctionBase]:
parser = OpenApiParser()
parsed_doc = parser.parse(openapi_document)
operations = parser.create_rest_api_operations(parsed_doc)
openapi_runner = OpenApiRunner(parsed_openapi_document=parsed_doc)
skill = {}
def create_run_operation_function(
runner: OpenApiRunner, operation: RestApiOperation
):
@sk_function(
description=operation.summary
if operation.summary
else operation.description,
name=operation_id,
)
@sk_function_context_parameter(
name="path_params", description="A dictionary of path parameters"
)
@sk_function_context_parameter(
name="query_params", description="A dictionary of query parameters"
)
@sk_function_context_parameter(
name="headers", description="A dictionary of headers"
)
@sk_function_context_parameter(
name="request_body", description="A dictionary of the request body"
)
async def run_openapi_operation(sk_context: SKContext) -> str:
path_params = sk_context.variables.get("path_params")
query_params = sk_context.variables.get("query_params")
headers = sk_context.variables.get("headers")
request_body = sk_context.variables.get("request_body")
response = await runner.run_operation(
operation,
path_params=json.loads(path_params) if path_params else None,
query_params=json.loads(query_params) if query_params else None,
headers=json.loads(headers) if headers else None,
request_body=json.loads(request_body) if request_body else None,
)
return response
return run_openapi_operation
for operation_id, operation in operations.items():
logger.info(f"Registering OpenAPI operation: {skill_name}.{operation_id}")
skill[operation_id] = create_run_operation_function(openapi_runner, operation)
return kernel.import_skill(skill, skill_name)