chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Generator
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from prance import ResolvingParser
|
||||
from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import RestApiExpectedResponse
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import RestApiParameter
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_location import RestApiParameterLocation
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload_property import RestApiPayloadProperty
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_requirement import RestApiSecurityRequirement
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_scheme import RestApiSecurityScheme
|
||||
from semantic_kernel.exceptions.function_exceptions import PluginInitializationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
|
||||
OpenAPIFunctionExecutionParameters,
|
||||
)
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OpenApiParser:
|
||||
"""NOTE: SK Python only supports the OpenAPI Spec >=3.0.
|
||||
|
||||
Import an OpenAPI file.
|
||||
|
||||
Args:
|
||||
openapi_file: The path to the OpenAPI file which can be local or a URL.
|
||||
|
||||
Returns:
|
||||
The parsed OpenAPI file
|
||||
|
||||
|
||||
:param openapi_file: The path to the OpenAPI file which can be local or a URL.
|
||||
:return: The parsed OpenAPI file
|
||||
"""
|
||||
|
||||
PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH: int = 10
|
||||
SUPPORTED_MEDIA_TYPES: Final[list[str]] = ["application/json", "text/plain"]
|
||||
|
||||
def parse(
|
||||
self,
|
||||
openapi_document: str,
|
||||
enable_file_ref_resolution: bool = False,
|
||||
enable_http_ref_resolution: bool = False,
|
||||
) -> Any | dict[str, Any] | None:
|
||||
"""Parse the OpenAPI document.
|
||||
|
||||
Args:
|
||||
openapi_document: The path or URL to the OpenAPI document.
|
||||
enable_file_ref_resolution: Whether to resolve local file $ref references.
|
||||
Disabled by default. When False, only internal JSON pointer references
|
||||
are resolved. Set to True if your OpenAPI spec is split across multiple
|
||||
local files.
|
||||
enable_http_ref_resolution: Whether to resolve external HTTP $ref references.
|
||||
Disabled by default.
|
||||
"""
|
||||
resolve_types = RESOLVE_INTERNAL
|
||||
if enable_file_ref_resolution:
|
||||
resolve_types |= RESOLVE_FILES
|
||||
if enable_http_ref_resolution:
|
||||
resolve_types |= RESOLVE_HTTP
|
||||
parser = ResolvingParser(openapi_document, resolve_types=resolve_types)
|
||||
return parser.specification
|
||||
|
||||
def _parse_parameters(self, parameters: list[dict[str, Any]]):
|
||||
"""Parse the parameters from the OpenAPI document."""
|
||||
result: list[RestApiParameter] = []
|
||||
for param in parameters:
|
||||
name: str = param["name"]
|
||||
if not param.get("in"):
|
||||
raise PluginInitializationError(f"Parameter {name} is missing 'in' field")
|
||||
if param.get("content", None) is not None:
|
||||
# The schema and content fields are mutually exclusive.
|
||||
raise PluginInitializationError(f"Parameter {name} cannot have a 'content' field. Expected: schema.")
|
||||
location = RestApiParameterLocation(param["in"])
|
||||
description: str | None = param.get("description", None)
|
||||
is_required: bool = param.get("required", False)
|
||||
default_value = param.get("default", None)
|
||||
schema: dict[str, Any] | None = param.get("schema", None)
|
||||
|
||||
result.append(
|
||||
RestApiParameter(
|
||||
name=name,
|
||||
type=schema.get("type", "string") if schema else "string",
|
||||
location=location,
|
||||
description=description,
|
||||
is_required=is_required,
|
||||
default_value=default_value,
|
||||
schema=schema if schema else {"type": "string"},
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def _get_payload_properties(self, operation_id, schema, required_properties, level=0):
|
||||
if schema is None:
|
||||
return []
|
||||
|
||||
if level > OpenApiParser.PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH:
|
||||
raise Exception(
|
||||
f"Max level {OpenApiParser.PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH} of "
|
||||
f"traversing payload properties of `{operation_id}` operation is exceeded."
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for property_name, property_schema in schema.get("properties", {}).items():
|
||||
default_value = property_schema.get("default", None)
|
||||
|
||||
property = RestApiPayloadProperty(
|
||||
name=property_name,
|
||||
type=property_schema.get("type", None),
|
||||
is_required=property_name in required_properties,
|
||||
properties=self._get_payload_properties(operation_id, property_schema, required_properties, level + 1),
|
||||
description=property_schema.get("description", None),
|
||||
schema=property_schema,
|
||||
default_value=default_value,
|
||||
)
|
||||
|
||||
result.append(property)
|
||||
|
||||
return result
|
||||
|
||||
def _create_rest_api_operation_payload(
|
||||
self, operation_id: str, request_body: dict[str, Any]
|
||||
) -> RestApiPayload | None:
|
||||
if request_body is None or request_body.get("content") is None:
|
||||
return None
|
||||
|
||||
content = request_body.get("content")
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
media_type = next((mt for mt in OpenApiParser.SUPPORTED_MEDIA_TYPES if mt in content), None)
|
||||
if media_type is None:
|
||||
raise Exception(f"Neither of the media types of {operation_id} is supported.")
|
||||
|
||||
media_type_metadata = content[media_type]
|
||||
payload_properties = self._get_payload_properties(
|
||||
operation_id, media_type_metadata["schema"], media_type_metadata["schema"].get("required", set())
|
||||
)
|
||||
return RestApiPayload(
|
||||
media_type,
|
||||
payload_properties,
|
||||
request_body.get("description"),
|
||||
schema=media_type_metadata.get("schema", None),
|
||||
)
|
||||
|
||||
def _create_response(self, responses: dict[str, Any]) -> Generator[tuple[str, RestApiExpectedResponse], None, None]:
|
||||
for response_key, response_value in responses.items():
|
||||
media_type = next(
|
||||
(mt for mt in OpenApiParser.SUPPORTED_MEDIA_TYPES if mt in response_value.get("content", {})), None
|
||||
)
|
||||
if media_type is not None:
|
||||
matching_schema = response_value["content"][media_type].get("schema", {})
|
||||
description = response_value.get("description") or matching_schema.get("description", "")
|
||||
yield (
|
||||
response_key,
|
||||
RestApiExpectedResponse(
|
||||
description=description,
|
||||
media_type=media_type,
|
||||
schema=matching_schema if matching_schema else None,
|
||||
),
|
||||
)
|
||||
|
||||
def _parse_security_schemes(self, components: dict) -> dict[str, dict]:
|
||||
security_schemes = {}
|
||||
schemes = components.get("securitySchemes", {})
|
||||
for scheme_name, scheme_data in schemes.items():
|
||||
security_schemes[scheme_name] = scheme_data
|
||||
return security_schemes
|
||||
|
||||
def _create_rest_api_security_scheme(self, security_scheme_data: dict) -> RestApiSecurityScheme:
|
||||
return RestApiSecurityScheme(
|
||||
security_scheme_type=security_scheme_data.get("type", ""),
|
||||
description=security_scheme_data.get("description"),
|
||||
name=security_scheme_data.get("name", ""),
|
||||
in_=security_scheme_data.get("in", ""),
|
||||
scheme=security_scheme_data.get("scheme", ""),
|
||||
bearer_format=security_scheme_data.get("bearerFormat"),
|
||||
flows=security_scheme_data.get("flows"),
|
||||
open_id_connect_url=security_scheme_data.get("openIdConnectUrl", ""),
|
||||
)
|
||||
|
||||
def _create_security_requirements(
|
||||
self,
|
||||
security: list[dict[str, list[str]]],
|
||||
security_schemes: dict[str, dict],
|
||||
) -> list[RestApiSecurityRequirement]:
|
||||
security_requirements: list[RestApiSecurityRequirement] = []
|
||||
|
||||
for requirement in security:
|
||||
for scheme_name, scopes in requirement.items():
|
||||
scheme_data = security_schemes.get(scheme_name)
|
||||
if not scheme_data:
|
||||
raise PluginInitializationError(f"Security scheme '{scheme_name}' is not defined in components.")
|
||||
scheme = self._create_rest_api_security_scheme(scheme_data)
|
||||
security_requirements.append(RestApiSecurityRequirement({scheme: scopes}))
|
||||
|
||||
return security_requirements
|
||||
|
||||
def create_rest_api_operations(
|
||||
self,
|
||||
parsed_document: Any,
|
||||
execution_settings: "OpenAPIFunctionExecutionParameters | None" = None,
|
||||
) -> dict[str, RestApiOperation]:
|
||||
"""Create REST API operations from the parsed OpenAPI document.
|
||||
|
||||
Args:
|
||||
parsed_document: The parsed OpenAPI document.
|
||||
execution_settings: The execution settings.
|
||||
|
||||
Returns:
|
||||
A dictionary of RestApiOperation instances.
|
||||
"""
|
||||
from semantic_kernel.connectors.openapi_plugin import OperationSelectionPredicateContext
|
||||
|
||||
components = parsed_document.get("components", {})
|
||||
security_schemes = self._parse_security_schemes(components)
|
||||
|
||||
paths = parsed_document.get("paths", {})
|
||||
request_objects = {}
|
||||
unique_operation_ids_registered: set[str] = set()
|
||||
|
||||
servers = parsed_document.get("servers", [])
|
||||
|
||||
server_urls: list[dict[str, Any]] = []
|
||||
|
||||
if execution_settings and execution_settings.server_url_override:
|
||||
# Override the servers with the provided URL
|
||||
server_urls = [{"url": execution_settings.server_url_override, "variables": {}}]
|
||||
elif servers:
|
||||
# Process servers, ensuring we capture their variables
|
||||
for server in servers:
|
||||
server_entry = {
|
||||
"url": server.get("url", "/"),
|
||||
"variables": server.get("variables", {}),
|
||||
"description": server.get("description", ""),
|
||||
}
|
||||
server_urls.append(server_entry)
|
||||
else:
|
||||
# Default server if none specified
|
||||
server_urls = [{"url": "/", "variables": {}, "description": ""}]
|
||||
|
||||
for path, methods in paths.items():
|
||||
for method, details in methods.items():
|
||||
request_method = method.lower()
|
||||
# Validate that operationId exists
|
||||
if "operationId" not in details:
|
||||
raise PluginInitializationError(f"operationId missing, path: '{path}', method: '{method}'")
|
||||
operationId = details["operationId"]
|
||||
if operationId in unique_operation_ids_registered:
|
||||
raise PluginInitializationError(
|
||||
f"Duplicate operationId: '{operationId}', path: '{path}', method: '{method}'"
|
||||
)
|
||||
unique_operation_ids_registered.add(operationId)
|
||||
|
||||
summary = details.get("summary", None)
|
||||
description = details.get("description", None)
|
||||
|
||||
# Exclude operations whose path would resolve to a different effective request target
|
||||
# than the one offered to the selection predicate: a dot-segment (encoded or literal)
|
||||
# or a non-relative (absolute / authority-changing) path. Excluding them here keeps
|
||||
# operation selection and request construction on one canonical target so such a path
|
||||
# cannot bypass an include/exclude operation-selection filter.
|
||||
if RestApiOperation._contains_dot_segment(path) or RestApiOperation._is_non_relative_path(path):
|
||||
logger.warning(
|
||||
f"Skipping operation {operationId} at path '{path}' because it does not resolve to a "
|
||||
f"relative path on the configured server."
|
||||
)
|
||||
continue
|
||||
|
||||
context = OperationSelectionPredicateContext(operationId, path, method, description)
|
||||
if (
|
||||
execution_settings
|
||||
and execution_settings.operation_selection_predicate
|
||||
and not execution_settings.operation_selection_predicate(context)
|
||||
):
|
||||
logger.info(f"Skipping operation {operationId} based on custom predicate.")
|
||||
continue
|
||||
|
||||
if execution_settings and operationId in execution_settings.operations_to_exclude:
|
||||
logger.info(f"Skipping operation {operationId} as it is excluded.")
|
||||
continue
|
||||
|
||||
parameters = details.get("parameters", [])
|
||||
parsed_params = self._parse_parameters(parameters)
|
||||
request_body = self._create_rest_api_operation_payload(operationId, details.get("requestBody", None))
|
||||
responses = dict(self._create_response(details.get("responses", {})))
|
||||
|
||||
operation_security = details.get("security", [])
|
||||
security_requirements = self._create_security_requirements(operation_security, security_schemes)
|
||||
|
||||
rest_api_operation = RestApiOperation(
|
||||
id=operationId,
|
||||
method=request_method,
|
||||
servers=server_urls,
|
||||
path=path,
|
||||
params=parsed_params,
|
||||
request_body=request_body,
|
||||
summary=summary,
|
||||
description=description,
|
||||
responses=OrderedDict(responses),
|
||||
security_requirements=security_requirements,
|
||||
)
|
||||
|
||||
request_objects[operationId] = rest_api_operation
|
||||
return request_objects
|
||||
Reference in New Issue
Block a user