chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
|
||||
OpenAPIFunctionExecutionParameters,
|
||||
)
|
||||
from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser
|
||||
from semantic_kernel.connectors.openapi_plugin.operation_selection_predicate_context import (
|
||||
OperationSelectionPredicateContext,
|
||||
)
|
||||
from semantic_kernel.connectors.openapi_plugin.server_url_validator import ServerUrlValidationOptions
|
||||
|
||||
__all__ = [
|
||||
"OpenAPIFunctionExecutionParameters",
|
||||
"OpenApiParser",
|
||||
"OperationSelectionPredicateContext",
|
||||
"ServerUrlValidationOptions",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class OperationExtensions(Enum):
|
||||
"""The operation extensions."""
|
||||
|
||||
METHOD_KEY = "method"
|
||||
OPERATION_KEY = "operation"
|
||||
INFO_KEY = "info"
|
||||
SECURITY_KEY = "security"
|
||||
SERVER_URLS_KEY = "server-urls"
|
||||
METADATA_KEY = "operation-extensions"
|
||||
@@ -0,0 +1,14 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiExpectedResponse:
|
||||
"""RestApiExpectedResponse."""
|
||||
|
||||
def __init__(self, description: str, media_type: str, schema: dict[str, str] | None = None):
|
||||
"""Initialize the RestApiExpectedResponse."""
|
||||
self.description = description
|
||||
self.media_type = media_type
|
||||
self.schema = schema
|
||||
@@ -0,0 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
@dataclass
|
||||
class RestApiOAuthFlow:
|
||||
"""Represents the OAuth flow used by the REST API."""
|
||||
|
||||
authorization_url: str
|
||||
token_url: str
|
||||
scopes: dict[str, str]
|
||||
refresh_url: str | None = None
|
||||
@@ -0,0 +1,17 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_oauth_flow import RestApiOAuthFlow
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
@dataclass
|
||||
class RestApiOAuthFlows:
|
||||
"""Represents the OAuth flows used by the REST API."""
|
||||
|
||||
implicit: RestApiOAuthFlow | None = None
|
||||
password: RestApiOAuthFlow | None = None
|
||||
client_credentials: RestApiOAuthFlow | None = None
|
||||
authorization_code: RestApiOAuthFlow | None = None
|
||||
@@ -0,0 +1,552 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import re
|
||||
from typing import Any, Final
|
||||
from urllib.parse import ParseResult, ParseResultBytes, quote, unquote, urlencode, urljoin, urlparse, urlunparse
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import (
|
||||
RestApiExpectedResponse,
|
||||
)
|
||||
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_parameter_style import (
|
||||
RestApiParameterStyle,
|
||||
)
|
||||
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.exceptions.function_exceptions import FunctionExecutionException
|
||||
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiOperation:
|
||||
"""RestApiOperation."""
|
||||
|
||||
MEDIA_TYPE_TEXT_PLAIN = "text/plain"
|
||||
PAYLOAD_ARGUMENT_NAME = "payload"
|
||||
CONTENT_TYPE_ARGUMENT_NAME = "content-type"
|
||||
INVALID_SYMBOLS_REGEX = re.compile(r"[^0-9A-Za-z_]+")
|
||||
|
||||
_preferred_responses: Final[list[str]] = [
|
||||
"200",
|
||||
"201",
|
||||
"202",
|
||||
"203",
|
||||
"204",
|
||||
"205",
|
||||
"206",
|
||||
"207",
|
||||
"208",
|
||||
"226",
|
||||
"2XX",
|
||||
"default",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
method: str,
|
||||
servers: list[dict[str, Any]],
|
||||
path: str,
|
||||
summary: str | None = None,
|
||||
description: str | None = None,
|
||||
params: list["RestApiParameter"] | None = None,
|
||||
request_body: "RestApiPayload | None" = None,
|
||||
responses: dict[str, "RestApiExpectedResponse"] | None = None,
|
||||
security_requirements: list[RestApiSecurityRequirement] | None = None,
|
||||
):
|
||||
"""Initialize the RestApiOperation."""
|
||||
self._id = id
|
||||
self._method = method.upper()
|
||||
self._servers = servers
|
||||
self._path = path
|
||||
self._summary = summary
|
||||
self._description = description
|
||||
self._parameters = params if params else []
|
||||
self._request_body = request_body
|
||||
self._responses = responses
|
||||
self._security_requirements = security_requirements
|
||||
self._is_frozen = False
|
||||
|
||||
def freeze(self):
|
||||
"""Make the instance and its components immutable."""
|
||||
self._is_frozen = True
|
||||
|
||||
if self.request_body:
|
||||
self.request_body.freeze()
|
||||
|
||||
for param in self.parameters:
|
||||
param.freeze()
|
||||
|
||||
def _throw_if_frozen(self):
|
||||
"""Raise an exception if the object is frozen."""
|
||||
if self._is_frozen:
|
||||
raise FunctionExecutionException(
|
||||
f"The `RestApiOperation` instance with id {self.id} is frozen and cannot be modified."
|
||||
)
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
"""Get the ID of the operation."""
|
||||
return self._id
|
||||
|
||||
@id.setter
|
||||
def id(self, value: str):
|
||||
self._throw_if_frozen()
|
||||
self._id = value
|
||||
|
||||
@property
|
||||
def method(self):
|
||||
"""Get the method of the operation."""
|
||||
return self._method
|
||||
|
||||
@method.setter
|
||||
def method(self, value: str):
|
||||
self._throw_if_frozen()
|
||||
self._method = value
|
||||
|
||||
@property
|
||||
def servers(self):
|
||||
"""Get the servers of the operation."""
|
||||
return self._servers
|
||||
|
||||
@servers.setter
|
||||
def servers(self, value: list[dict[str, Any]]):
|
||||
self._throw_if_frozen()
|
||||
self._servers = value
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
"""Get the path of the operation."""
|
||||
return self._path
|
||||
|
||||
@path.setter
|
||||
def path(self, value: str):
|
||||
self._throw_if_frozen()
|
||||
self._path = value
|
||||
|
||||
@property
|
||||
def summary(self):
|
||||
"""Get the summary of the operation."""
|
||||
return self._summary
|
||||
|
||||
@summary.setter
|
||||
def summary(self, value: str | None):
|
||||
self._throw_if_frozen()
|
||||
self._summary = value
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""Get the description of the operation."""
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value: str | None):
|
||||
self._throw_if_frozen()
|
||||
self._description = value
|
||||
|
||||
@property
|
||||
def parameters(self):
|
||||
"""Get the parameters of the operation."""
|
||||
return self._parameters
|
||||
|
||||
@parameters.setter
|
||||
def parameters(self, value: list["RestApiParameter"]):
|
||||
self._throw_if_frozen()
|
||||
self._parameters = value
|
||||
|
||||
@property
|
||||
def request_body(self):
|
||||
"""Get the request body of the operation."""
|
||||
return self._request_body
|
||||
|
||||
@request_body.setter
|
||||
def request_body(self, value: "RestApiPayload | None"):
|
||||
self._throw_if_frozen()
|
||||
self._request_body = value
|
||||
|
||||
@property
|
||||
def responses(self):
|
||||
"""Get the responses of the operation."""
|
||||
return self._responses
|
||||
|
||||
@responses.setter
|
||||
def responses(self, value: dict[str, "RestApiExpectedResponse"] | None):
|
||||
self._throw_if_frozen()
|
||||
self._responses = value
|
||||
|
||||
@property
|
||||
def security_requirements(self):
|
||||
"""Get the security requirements of the operation."""
|
||||
return self._security_requirements
|
||||
|
||||
@security_requirements.setter
|
||||
def security_requirements(self, value: list[RestApiSecurityRequirement] | None):
|
||||
self._throw_if_frozen()
|
||||
self._security_requirements = value
|
||||
|
||||
def url_join(self, base_url: str, path: str):
|
||||
"""Join a base URL and a path, correcting for any missing slashes."""
|
||||
parsed_base = urlparse(base_url)
|
||||
base_path = parsed_base.path + "/" if not parsed_base.path.endswith("/") else parsed_base.path
|
||||
full_path = urljoin(base_path, path.lstrip("/"))
|
||||
return urlunparse(parsed_base._replace(path=full_path))
|
||||
|
||||
def build_headers(self, arguments: dict[str, Any]) -> dict[str, str]:
|
||||
"""Build the headers for the operation."""
|
||||
headers = {}
|
||||
|
||||
parameters = [p for p in self.parameters if p.location == RestApiParameterLocation.HEADER]
|
||||
|
||||
for parameter in parameters:
|
||||
argument = arguments.get(parameter.name)
|
||||
|
||||
if argument is None:
|
||||
if parameter.is_required:
|
||||
raise FunctionExecutionException(
|
||||
f"No argument is provided for the `{parameter.name}` "
|
||||
f"required parameter of the operation - `{self.id}`."
|
||||
)
|
||||
continue
|
||||
|
||||
headers[parameter.name] = str(argument)
|
||||
|
||||
return headers
|
||||
|
||||
def build_operation_url(self, arguments, server_url_override=None, api_host_url=None):
|
||||
"""Build the URL for the operation."""
|
||||
server_url = self.get_server_url(server_url_override, api_host_url, arguments)
|
||||
path = self.build_path(self.path, arguments)
|
||||
try:
|
||||
request_url = urljoin(server_url, path.lstrip("/"))
|
||||
except Exception as e:
|
||||
raise FunctionExecutionException(f"Error building the URL for the operation {self.id}: {e!s}") from e
|
||||
self._ensure_request_target_matches_server(server_url, request_url)
|
||||
return request_url
|
||||
|
||||
@staticmethod
|
||||
def _ensure_request_target_matches_server(server_url: str, request_url: str) -> None:
|
||||
"""Verify URL construction did not move the request off the configured server.
|
||||
|
||||
A selected operation path must resolve to a request on the same scheme, host, and port and
|
||||
within the server's base path. Otherwise an absolute or authority-changing operation path
|
||||
(for example "https://another-host/admin") could redirect a credential-bearing request to an
|
||||
unintended target even though it carries no dot-segment. This complements
|
||||
`_validate_path_segments` so operation selection, path validation, and request construction
|
||||
share one canonical target.
|
||||
"""
|
||||
server = urlparse(server_url)
|
||||
request = urlparse(request_url)
|
||||
|
||||
if (request.scheme, request.username, request.password, request.hostname, request.port) != (
|
||||
server.scheme,
|
||||
server.username,
|
||||
server.password,
|
||||
server.hostname,
|
||||
server.port,
|
||||
):
|
||||
raise FunctionExecutionException(
|
||||
f"The operation path resolves to '{request.scheme}://{request.netloc}', which does not match "
|
||||
f"the configured server '{server.scheme}://{server.netloc}'."
|
||||
)
|
||||
|
||||
# get_server_url guarantees a trailing slash, so the server base path always ends with "/".
|
||||
base_path = server.path
|
||||
if request.path != base_path.rstrip("/") and not request.path.startswith(base_path):
|
||||
raise FunctionExecutionException(
|
||||
f"The operation path resolves to '{request.path}', which is outside the configured server "
|
||||
f"base path '{base_path}'."
|
||||
)
|
||||
|
||||
def get_server_url(self, server_url_override=None, api_host_url=None, arguments=None):
|
||||
"""Get the server URL for the operation."""
|
||||
if arguments is None:
|
||||
arguments = {}
|
||||
|
||||
# Prioritize server_url_override
|
||||
if (
|
||||
server_url_override is not None
|
||||
and isinstance(server_url_override, (ParseResult, ParseResultBytes))
|
||||
and server_url_override.geturl() != b""
|
||||
):
|
||||
server_url_string = server_url_override.geturl()
|
||||
elif server_url_override is not None and isinstance(server_url_override, str) and server_url_override != "":
|
||||
server_url_string = server_url_override
|
||||
elif self.servers and len(self.servers) > 0:
|
||||
# Use the first server by default
|
||||
server = self.servers[0]
|
||||
server_url_string = server["url"] if isinstance(server, dict) else server
|
||||
server_variables = server.get("variables", {}) if isinstance(server, dict) else {}
|
||||
|
||||
# Substitute server variables if available
|
||||
for variable_name, variable_def in server_variables.items():
|
||||
argument_name = variable_def.get("argument_name", variable_name)
|
||||
allowed_values = variable_def.get("enum")
|
||||
if argument_name in arguments:
|
||||
value = str(arguments[argument_name])
|
||||
elif "default" in variable_def and variable_def["default"] is not None:
|
||||
# Use the default value if no argument is provided
|
||||
value = str(variable_def["default"])
|
||||
else:
|
||||
# Raise an exception if no value is available
|
||||
raise FunctionExecutionException(
|
||||
f"No argument provided for the '{variable_name}' server variable of the operation '{self.id}'."
|
||||
)
|
||||
if allowed_values is not None and value not in allowed_values:
|
||||
raise FunctionExecutionException(
|
||||
f"Value '{value}' for server variable '{variable_name}' is not one of the allowed values."
|
||||
)
|
||||
server_url_string = server_url_string.replace(f"{{{variable_name}}}", quote(value, safe=""))
|
||||
elif self.server_url:
|
||||
server_url_string = self.server_url
|
||||
elif api_host_url is not None:
|
||||
server_url_string = api_host_url
|
||||
else:
|
||||
raise FunctionExecutionException(f"No valid server URL for operation {self.id}")
|
||||
|
||||
# Ensure the base URL ends with a trailing slash
|
||||
if not server_url_string.endswith("/"):
|
||||
server_url_string += "/"
|
||||
|
||||
return server_url_string # Return the URL string directly
|
||||
|
||||
def build_path(self, path_template: str, arguments: dict[str, Any]) -> str:
|
||||
"""Build the path for the operation."""
|
||||
parameters = [p for p in self.parameters if p.location == RestApiParameterLocation.PATH]
|
||||
for parameter in parameters:
|
||||
argument = arguments.get(parameter.name)
|
||||
if argument is None:
|
||||
if parameter.is_required:
|
||||
raise FunctionExecutionException(
|
||||
f"No argument is provided for the `{parameter.name}` "
|
||||
f"required parameter of the operation - `{self.id}`."
|
||||
)
|
||||
continue
|
||||
path_template = path_template.replace(f"{{{parameter.name}}}", quote(str(argument), safe=""))
|
||||
self._validate_path_segments(path_template)
|
||||
return path_template
|
||||
|
||||
@staticmethod
|
||||
def _validate_path_segments(path: str) -> None:
|
||||
"""Reject dot-segments (. or ..), including percent-encoded forms, that enable path traversal.
|
||||
|
||||
The operation is selected using the raw path but the request URL is built from a canonicalized
|
||||
path, so encoded dot-segments such as "%2e%2e" must be rejected before the URL is constructed.
|
||||
"""
|
||||
if RestApiOperation._contains_dot_segment(path):
|
||||
raise FunctionExecutionException(
|
||||
f"Path '{path}' contains a dot-segment, which could lead to path traversal."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _contains_dot_segment(path: str) -> bool:
|
||||
"""Return True if the path contains a dot-segment (. or ..), including percent-encoded forms.
|
||||
|
||||
Used both to reject such paths when building a request URL and to exclude them during operation
|
||||
selection so an encoded dot-segment cannot bypass an include/exclude operation-selection filter.
|
||||
"""
|
||||
if not path:
|
||||
return False
|
||||
for segment in path.split("/"):
|
||||
decoded = segment
|
||||
for _ in range(5):
|
||||
unescaped = unquote(decoded)
|
||||
if unescaped == decoded:
|
||||
break
|
||||
decoded = unescaped
|
||||
# A decoded segment may contain encoded separators ("%2f"/"%5c"), so re-split on
|
||||
# both "/" and "\" and reject any resulting dot-segment.
|
||||
for part in decoded.replace("\\", "/").split("/"):
|
||||
if part in (".", ".."):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_non_relative_path(path: str) -> bool:
|
||||
"""Return True if the path is non-relative (absolute or authority-changing).
|
||||
|
||||
A conformant OpenAPI operation path is relative to the server URL. An absolute URI, a
|
||||
protocol-relative reference ("//host"), or a backslash-authority reference changes the request
|
||||
target once combined with the server URL, so such a path is excluded during operation selection
|
||||
to keep selection and request construction on one canonical target.
|
||||
"""
|
||||
if not path:
|
||||
return False
|
||||
if path.startswith(("//", "\\\\", "/\\", "\\/")):
|
||||
return True
|
||||
parsed = urlparse(path)
|
||||
return bool(parsed.scheme or parsed.netloc)
|
||||
|
||||
def build_query_string(self, arguments: dict[str, Any]) -> str:
|
||||
"""Build the query string for the operation."""
|
||||
segments = []
|
||||
parameters = [p for p in self.parameters if p.location == RestApiParameterLocation.QUERY]
|
||||
for parameter in parameters:
|
||||
argument = arguments.get(parameter.name)
|
||||
if argument is None:
|
||||
if parameter.is_required:
|
||||
raise FunctionExecutionException(
|
||||
f"No argument or value is provided for the `{parameter.name}` "
|
||||
f"required parameter of the operation - `{self.id}`."
|
||||
)
|
||||
continue
|
||||
segments.append((parameter.name, argument))
|
||||
return urlencode(segments)
|
||||
|
||||
def replace_invalid_symbols(self, parameter_name):
|
||||
"""Replace invalid symbols in the parameter name with underscores."""
|
||||
return RestApiOperation.INVALID_SYMBOLS_REGEX.sub("_", parameter_name)
|
||||
|
||||
def get_parameters(
|
||||
self,
|
||||
operation: "RestApiOperation",
|
||||
add_payload_params_from_metadata: bool = True,
|
||||
enable_payload_spacing: bool = False,
|
||||
) -> list["RestApiParameter"]:
|
||||
"""Get the parameters for the operation."""
|
||||
params = list(operation.parameters) if operation.parameters is not None else []
|
||||
if operation.request_body is not None:
|
||||
params.extend(
|
||||
self.get_payload_parameters(
|
||||
operation=operation,
|
||||
use_parameters_from_metadata=add_payload_params_from_metadata,
|
||||
enable_namespacing=enable_payload_spacing,
|
||||
)
|
||||
)
|
||||
|
||||
for parameter in params:
|
||||
parameter.alternative_name = self.replace_invalid_symbols(parameter.name)
|
||||
|
||||
return params
|
||||
|
||||
def create_payload_artificial_parameter(self, operation: "RestApiOperation") -> "RestApiParameter":
|
||||
"""Create an artificial parameter for the REST API request body."""
|
||||
return RestApiParameter(
|
||||
name=self.PAYLOAD_ARGUMENT_NAME,
|
||||
type=(
|
||||
"string"
|
||||
if operation.request_body
|
||||
and operation.request_body.media_type == RestApiOperation.MEDIA_TYPE_TEXT_PLAIN
|
||||
else "object"
|
||||
),
|
||||
is_required=True,
|
||||
location=RestApiParameterLocation.BODY,
|
||||
style=RestApiParameterStyle.SIMPLE,
|
||||
description=operation.request_body.description if operation.request_body else "REST API request body.",
|
||||
schema=operation.request_body.schema if operation.request_body else None,
|
||||
)
|
||||
|
||||
def create_content_type_artificial_parameter(self) -> "RestApiParameter":
|
||||
"""Create an artificial parameter for the content type of the REST API request body."""
|
||||
return RestApiParameter(
|
||||
name=self.CONTENT_TYPE_ARGUMENT_NAME,
|
||||
type="string",
|
||||
is_required=False,
|
||||
location=RestApiParameterLocation.BODY,
|
||||
style=RestApiParameterStyle.SIMPLE,
|
||||
description="Content type of REST API request body.",
|
||||
)
|
||||
|
||||
def _get_property_name(self, property: RestApiPayloadProperty, root_property_name: bool, enable_namespacing: bool):
|
||||
if enable_namespacing and root_property_name:
|
||||
return f"{root_property_name}.{property.name}"
|
||||
return property.name
|
||||
|
||||
def _get_parameters_from_payload_metadata(
|
||||
self,
|
||||
properties: list["RestApiPayloadProperty"],
|
||||
enable_namespacing: bool = False,
|
||||
root_property_name: bool | None = None,
|
||||
) -> list["RestApiParameter"]:
|
||||
parameters: list[RestApiParameter] = []
|
||||
for property in properties:
|
||||
parameter_name = self._get_property_name(property, root_property_name or False, enable_namespacing)
|
||||
if not hasattr(property, "properties") or not property.properties:
|
||||
parameters.append(
|
||||
RestApiParameter(
|
||||
name=parameter_name,
|
||||
type=property.type,
|
||||
is_required=property.is_required,
|
||||
location=RestApiParameterLocation.BODY,
|
||||
style=RestApiParameterStyle.SIMPLE,
|
||||
description=property.description,
|
||||
schema=property.schema,
|
||||
)
|
||||
)
|
||||
else:
|
||||
# Handle property.properties as a single instance or a list
|
||||
if isinstance(property.properties, RestApiPayloadProperty):
|
||||
nested_properties = [property.properties]
|
||||
else:
|
||||
nested_properties = property.properties
|
||||
|
||||
parameters.extend(
|
||||
self._get_parameters_from_payload_metadata(nested_properties, enable_namespacing, parameter_name)
|
||||
)
|
||||
return parameters
|
||||
|
||||
def get_payload_parameters(
|
||||
self, operation: "RestApiOperation", use_parameters_from_metadata: bool, enable_namespacing: bool
|
||||
):
|
||||
"""Get the payload parameters for the operation."""
|
||||
if use_parameters_from_metadata:
|
||||
if operation.request_body is None:
|
||||
raise Exception(
|
||||
f"Payload parameters cannot be retrieved from the `{operation.id}` "
|
||||
f"operation payload metadata because it is missing."
|
||||
)
|
||||
if operation.request_body.media_type == RestApiOperation.MEDIA_TYPE_TEXT_PLAIN:
|
||||
return [self.create_payload_artificial_parameter(operation)]
|
||||
|
||||
return self._get_parameters_from_payload_metadata(operation.request_body.properties, enable_namespacing)
|
||||
|
||||
return [
|
||||
self.create_payload_artificial_parameter(operation),
|
||||
self.create_content_type_artificial_parameter(),
|
||||
]
|
||||
|
||||
def get_default_response(
|
||||
self, responses: dict[str, RestApiExpectedResponse], preferred_responses: list[str]
|
||||
) -> RestApiExpectedResponse | None:
|
||||
"""Get the default response for the operation.
|
||||
|
||||
If no appropriate response is found, returns None.
|
||||
"""
|
||||
for code in preferred_responses:
|
||||
if code in responses:
|
||||
return responses[code]
|
||||
return None
|
||||
|
||||
def get_default_return_parameter(self, preferred_responses: list[str] | None = None) -> KernelParameterMetadata:
|
||||
"""Get the default return parameter for the operation."""
|
||||
if preferred_responses is None:
|
||||
preferred_responses = self._preferred_responses
|
||||
|
||||
responses = self.responses if self.responses is not None else {}
|
||||
|
||||
rest_operation_response = self.get_default_response(responses, preferred_responses)
|
||||
|
||||
schema_type = None
|
||||
if rest_operation_response is not None and rest_operation_response.schema is not None:
|
||||
schema_type = rest_operation_response.schema.get("type")
|
||||
|
||||
if rest_operation_response:
|
||||
return KernelParameterMetadata(
|
||||
name="return",
|
||||
description=rest_operation_response.description,
|
||||
type_=schema_type,
|
||||
schema_data=rest_operation_response.schema,
|
||||
)
|
||||
|
||||
return KernelParameterMetadata(
|
||||
name="return",
|
||||
description="Default return parameter",
|
||||
type_="string",
|
||||
schema_data={"type": "string"},
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import (
|
||||
RestApiExpectedResponse,
|
||||
)
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_location import (
|
||||
RestApiParameterLocation,
|
||||
)
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_style import (
|
||||
RestApiParameterStyle,
|
||||
)
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiParameter:
|
||||
"""RestApiParameter."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
type: str,
|
||||
location: RestApiParameterLocation,
|
||||
style: RestApiParameterStyle | None = None,
|
||||
alternative_name: str | None = None,
|
||||
description: str | None = None,
|
||||
is_required: bool = False,
|
||||
default_value: Any | None = None,
|
||||
schema: str | dict | None = None,
|
||||
response: RestApiExpectedResponse | None = None,
|
||||
):
|
||||
"""Initialize the RestApiParameter."""
|
||||
self._name = name
|
||||
self._type = type
|
||||
self._location = location
|
||||
self._style = style
|
||||
self._alternative_name = alternative_name
|
||||
self._description = description
|
||||
self._is_required = is_required
|
||||
self._default_value = default_value
|
||||
self._schema = schema
|
||||
self._response = response
|
||||
self._is_frozen = False
|
||||
|
||||
def freeze(self):
|
||||
"""Make the instance immutable."""
|
||||
self._is_frozen = True
|
||||
|
||||
def _throw_if_frozen(self):
|
||||
"""Raise an exception if the object is frozen."""
|
||||
if self._is_frozen:
|
||||
raise FunctionExecutionException("This `RestApiParameter` instance is frozen and cannot be modified.")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Get the name of the parameter."""
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str):
|
||||
self._throw_if_frozen()
|
||||
self._name = value
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Get the type of the parameter."""
|
||||
return self._type
|
||||
|
||||
@type.setter
|
||||
def type(self, value: str):
|
||||
self._throw_if_frozen()
|
||||
self._type = value
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
"""Get the location of the parameter."""
|
||||
return self._location
|
||||
|
||||
@location.setter
|
||||
def location(self, value: RestApiParameterLocation):
|
||||
self._throw_if_frozen()
|
||||
self._location = value
|
||||
|
||||
@property
|
||||
def style(self):
|
||||
"""Get the style of the parameter."""
|
||||
return self._style
|
||||
|
||||
@style.setter
|
||||
def style(self, value: RestApiParameterStyle | None):
|
||||
self._throw_if_frozen()
|
||||
self._style = value
|
||||
|
||||
@property
|
||||
def alternative_name(self):
|
||||
"""Get the alternative name of the parameter."""
|
||||
return self._alternative_name
|
||||
|
||||
@alternative_name.setter
|
||||
def alternative_name(self, value: str | None):
|
||||
self._throw_if_frozen()
|
||||
self._alternative_name = value
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""Get the description of the parameter."""
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value: str | None):
|
||||
self._throw_if_frozen()
|
||||
self._description = value
|
||||
|
||||
@property
|
||||
def is_required(self):
|
||||
"""Get whether the parameter is required."""
|
||||
return self._is_required
|
||||
|
||||
@is_required.setter
|
||||
def is_required(self, value: bool):
|
||||
self._throw_if_frozen()
|
||||
self._is_required = value
|
||||
|
||||
@property
|
||||
def default_value(self):
|
||||
"""Get the default value of the parameter."""
|
||||
return self._default_value
|
||||
|
||||
@default_value.setter
|
||||
def default_value(self, value: Any | None):
|
||||
self._throw_if_frozen()
|
||||
self._default_value = value
|
||||
|
||||
@property
|
||||
def schema(self):
|
||||
"""Get the schema of the parameter."""
|
||||
return self._schema
|
||||
|
||||
@schema.setter
|
||||
def schema(self, value: str | dict | None):
|
||||
self._throw_if_frozen()
|
||||
self._schema = value
|
||||
|
||||
@property
|
||||
def response(self):
|
||||
"""Get the response of the parameter."""
|
||||
return self._response
|
||||
|
||||
@response.setter
|
||||
def response(self, value: Any | None):
|
||||
self._throw_if_frozen()
|
||||
self._response = value
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiParameterLocation(Enum):
|
||||
"""The location of the REST API parameter."""
|
||||
|
||||
PATH = "path"
|
||||
QUERY = "query"
|
||||
HEADER = "header"
|
||||
COOKIE = "cookie"
|
||||
BODY = "body"
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiParameterStyle(Enum):
|
||||
"""RestApiParameterStyle."""
|
||||
|
||||
SIMPLE = "simple"
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload_property import (
|
||||
RestApiPayloadProperty,
|
||||
)
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiPayload:
|
||||
"""RestApiPayload."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
media_type: str,
|
||||
properties: list[RestApiPayloadProperty],
|
||||
description: str | None = None,
|
||||
schema: str | None = None,
|
||||
):
|
||||
"""Initialize the RestApiPayload."""
|
||||
self._media_type = media_type
|
||||
self._properties = properties
|
||||
self._description = description
|
||||
self._schema = schema
|
||||
self._is_frozen = False
|
||||
|
||||
def freeze(self):
|
||||
"""Make the instance immutable and freeze properties."""
|
||||
self._is_frozen = True
|
||||
for property in self._properties:
|
||||
property.freeze()
|
||||
|
||||
def _throw_if_frozen(self):
|
||||
"""Raise an exception if the object is frozen."""
|
||||
if self._is_frozen:
|
||||
raise FunctionExecutionException("This `RestApiPayload` instance is frozen and cannot be modified.")
|
||||
|
||||
@property
|
||||
def media_type(self):
|
||||
"""Get the media type of the payload."""
|
||||
return self._media_type
|
||||
|
||||
@media_type.setter
|
||||
def media_type(self, value: str):
|
||||
self._throw_if_frozen()
|
||||
self._media_type = value
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""Get the description of the payload."""
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value: str | None):
|
||||
self._throw_if_frozen()
|
||||
self._description = value
|
||||
|
||||
@property
|
||||
def properties(self):
|
||||
"""Get the properties of the payload."""
|
||||
return self._properties
|
||||
|
||||
@properties.setter
|
||||
def properties(self, value: list[RestApiPayloadProperty]):
|
||||
self._throw_if_frozen()
|
||||
self._properties = value
|
||||
|
||||
@property
|
||||
def schema(self):
|
||||
"""Get the schema of the payload."""
|
||||
return self._schema
|
||||
|
||||
@schema.setter
|
||||
def schema(self, value: str | None):
|
||||
self._throw_if_frozen()
|
||||
self._schema = value
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiPayloadProperty:
|
||||
"""RestApiPayloadProperty."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
type: str,
|
||||
properties: list["RestApiPayloadProperty"] | None = None,
|
||||
description: str | None = None,
|
||||
is_required: bool = False,
|
||||
default_value: Any | None = None,
|
||||
schema: str | None = None,
|
||||
):
|
||||
"""Initialize the RestApiPayloadProperty."""
|
||||
self._name = name
|
||||
self._type = type
|
||||
self._properties = properties or []
|
||||
self._description = description
|
||||
self._is_required = is_required
|
||||
self._default_value = default_value
|
||||
self._schema = schema
|
||||
self._is_frozen = False
|
||||
|
||||
def freeze(self):
|
||||
"""Make the instance immutable, and freeze nested properties."""
|
||||
self._is_frozen = True
|
||||
for prop in self._properties:
|
||||
prop.freeze()
|
||||
|
||||
def _throw_if_frozen(self):
|
||||
"""Raise an exception if the object is frozen."""
|
||||
if self._is_frozen:
|
||||
raise FunctionExecutionException("This instance is frozen and cannot be modified.")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Get the name of the property."""
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str):
|
||||
self._throw_if_frozen()
|
||||
self._name = value
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Get the type of the property."""
|
||||
return self._type
|
||||
|
||||
@type.setter
|
||||
def type(self, value: str):
|
||||
self._throw_if_frozen()
|
||||
self._type = value
|
||||
|
||||
@property
|
||||
def properties(self):
|
||||
"""Get the properties of the property."""
|
||||
return self._properties
|
||||
|
||||
@properties.setter
|
||||
def properties(self, value: list["RestApiPayloadProperty"]):
|
||||
self._throw_if_frozen()
|
||||
self._properties = value
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""Get the description of the property."""
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value: str | None):
|
||||
self._throw_if_frozen()
|
||||
self._description = value
|
||||
|
||||
@property
|
||||
def is_required(self):
|
||||
"""Get whether the property is required."""
|
||||
return self._is_required
|
||||
|
||||
@is_required.setter
|
||||
def is_required(self, value: bool):
|
||||
self._throw_if_frozen()
|
||||
self._is_required = value
|
||||
|
||||
@property
|
||||
def default_value(self):
|
||||
"""Get the default value of the property."""
|
||||
return self._default_value
|
||||
|
||||
@default_value.setter
|
||||
def default_value(self, value: Any | None):
|
||||
self._throw_if_frozen()
|
||||
self._default_value = value
|
||||
|
||||
@property
|
||||
def schema(self):
|
||||
"""Get the schema of the property."""
|
||||
return self._schema
|
||||
|
||||
@schema.setter
|
||||
def schema(self, value: str | None):
|
||||
self._throw_if_frozen()
|
||||
self._schema = value
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
class RestApiRunOptions:
|
||||
"""The options for running the REST API operation."""
|
||||
|
||||
def __init__(
|
||||
self, server_url_override: str | None = None, api_host_url: str | None = None, timeout: float | None = None
|
||||
) -> None:
|
||||
"""Initialize the REST API operation run options.
|
||||
|
||||
Args:
|
||||
server_url_override: The server URL override, if any.
|
||||
api_host_url: The API host URL, if any.
|
||||
timeout: The timeout for the operation, if any.
|
||||
"""
|
||||
self.server_url_override: str | None = server_url_override
|
||||
self.api_host_url: str | None = api_host_url
|
||||
self.timeout: float | None = timeout
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_scheme import RestApiSecurityScheme
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiSecurityRequirement(dict[RestApiSecurityScheme, list[str]]):
|
||||
"""Represents the security requirements used by the REST API."""
|
||||
|
||||
def __init__(self, dictionary: dict[RestApiSecurityScheme, list[str]]):
|
||||
"""Initializes a new instance of the RestApiSecurityRequirement class."""
|
||||
super().__init__(dictionary)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_oauth_flows import RestApiOAuthFlows
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_location import (
|
||||
RestApiParameterLocation,
|
||||
)
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class RestApiSecurityScheme:
|
||||
"""Represents the security scheme used by the REST API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
security_scheme_type: str,
|
||||
name: str,
|
||||
in_: RestApiParameterLocation,
|
||||
scheme: str,
|
||||
open_id_connect_url: str,
|
||||
description: str | None = None,
|
||||
bearer_format: str | None = None,
|
||||
flows: RestApiOAuthFlows | None = None,
|
||||
):
|
||||
"""Initializes a new instance of the RestApiSecurityScheme class."""
|
||||
self.security_scheme_type = security_scheme_type
|
||||
self.description = description
|
||||
self.name = name
|
||||
self.in_ = in_
|
||||
self.scheme = scheme
|
||||
self.bearer_format = bearer_format
|
||||
self.flows = flows
|
||||
self.open_id_connect_url = open_id_connect_url
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
|
||||
@experimental
|
||||
class Uri:
|
||||
"""The Uri class that represents the URI."""
|
||||
|
||||
def __init__(self, uri):
|
||||
"""Initialize the Uri."""
|
||||
self.uri = uri
|
||||
|
||||
def get_left_part(self):
|
||||
"""Get the left part of the URI."""
|
||||
parsed_uri = urlparse(self.uri)
|
||||
return f"{parsed_uri.scheme}://{parsed_uri.netloc}"
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import Field
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.operation_selection_predicate_context import (
|
||||
OperationSelectionPredicateContext,
|
||||
)
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
AuthCallbackType = Callable[..., Awaitable[Any]]
|
||||
|
||||
|
||||
class OpenAPIFunctionExecutionParameters(KernelBaseModel):
|
||||
"""OpenAPI function execution parameters.
|
||||
|
||||
OpenAPI operation request URLs are validated by default to reduce SSRF risk. Requests must use HTTPS
|
||||
and must not resolve to private, loopback, link-local, or otherwise non-public IP addresses unless the
|
||||
target is explicitly trusted through `server_url_validation_allowed_base_urls` or
|
||||
`allow_private_network_access`.
|
||||
"""
|
||||
|
||||
http_client: httpx.AsyncClient | None = None
|
||||
auth_callback: AuthCallbackType | None = None
|
||||
server_url_override: str | None = None
|
||||
ignore_non_compliant_errors: bool = False
|
||||
user_agent: str | None = None
|
||||
enable_dynamic_payload: bool = True
|
||||
enable_payload_namespacing: bool = False
|
||||
operations_to_exclude: list[str] = Field(default_factory=list, description="The operationId(s) to exclude")
|
||||
operation_selection_predicate: Callable[[OperationSelectionPredicateContext], bool] | None = None
|
||||
timeout: float | None = Field(
|
||||
None, description="Default timeout in seconds for HTTP requests. Uses httpx default (5 seconds) if None."
|
||||
)
|
||||
enable_file_ref_resolution: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Whether to resolve local file $ref references when parsing OpenAPI documents. "
|
||||
"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 and you trust "
|
||||
"the document source."
|
||||
),
|
||||
)
|
||||
enable_http_ref_resolution: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Whether to resolve external HTTP $ref references when parsing OpenAPI documents. "
|
||||
"Disabled by default. Set to True only if you trust the OpenAPI document source "
|
||||
"and need external HTTP $ref resolution."
|
||||
),
|
||||
)
|
||||
server_url_validation_allowed_base_urls: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Base URLs that are explicitly allowed for OpenAPI operation requests. Matching URLs bypass "
|
||||
"the default HTTPS-only and private-network validation gates. Set only for trusted endpoints."
|
||||
),
|
||||
)
|
||||
allow_private_network_access: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"Whether OpenAPI operation requests may target private, loopback, link-local, or otherwise "
|
||||
"non-public IP addresses. Disabled by default to prevent SSRF."
|
||||
),
|
||||
)
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Post initialization method for the model."""
|
||||
from semantic_kernel.connectors.openapi_plugin.server_url_validator import ServerUrlValidationOptions
|
||||
from semantic_kernel.utils.telemetry.user_agent import HTTP_USER_AGENT
|
||||
|
||||
if self.server_url_override:
|
||||
parsed_url = urlparse(self.server_url_override)
|
||||
if not parsed_url.scheme or not parsed_url.netloc:
|
||||
raise ValueError(f"Invalid server_url_override: {self.server_url_override}")
|
||||
|
||||
ServerUrlValidationOptions(allowed_base_urls=self.server_url_validation_allowed_base_urls)
|
||||
|
||||
if not self.user_agent:
|
||||
self.user_agent = HTTP_USER_AGENT
|
||||
@@ -0,0 +1,213 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from semantic_kernel.connectors.openapi_plugin.const import OperationExtensions
|
||||
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_run_options import RestApiRunOptions
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_security_requirement import RestApiSecurityRequirement
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_uri import Uri
|
||||
from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser
|
||||
from semantic_kernel.connectors.openapi_plugin.openapi_runner import OpenApiRunner
|
||||
from semantic_kernel.connectors.openapi_plugin.server_url_validator import ServerUrlValidationOptions
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.functions.kernel_function_decorator import kernel_function
|
||||
from semantic_kernel.functions.kernel_function_from_method import KernelFunctionFromMethod
|
||||
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
|
||||
from semantic_kernel.schema.kernel_json_schema_builder import TYPE_MAPPING
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
|
||||
OpenAPIFunctionExecutionParameters,
|
||||
)
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@experimental
|
||||
def create_functions_from_openapi(
|
||||
plugin_name: str,
|
||||
openapi_document_path: str | None = None,
|
||||
openapi_parsed_spec: dict[str, Any] | None = None,
|
||||
execution_settings: "OpenAPIFunctionExecutionParameters | None" = None,
|
||||
) -> list[KernelFunctionFromMethod]:
|
||||
"""Creates the functions from OpenAPI document.
|
||||
|
||||
Args:
|
||||
plugin_name: The name of the plugin
|
||||
openapi_document_path: The OpenAPI document path, it must be a file path to the spec (optional)
|
||||
openapi_parsed_spec: The parsed OpenAPI spec (optional)
|
||||
execution_settings: The execution settings
|
||||
|
||||
Returns:
|
||||
list[KernelFunctionFromMethod]: the operations as functions
|
||||
"""
|
||||
parsed_doc: dict[str, Any] | Any = None
|
||||
if openapi_parsed_spec is not None:
|
||||
parsed_doc = openapi_parsed_spec
|
||||
else:
|
||||
if openapi_document_path is None:
|
||||
raise FunctionExecutionException(
|
||||
"Either `openapi_document_path` or `openapi_parsed_spec` must be provided."
|
||||
)
|
||||
|
||||
# Parse the document from the given path
|
||||
parser = OpenApiParser()
|
||||
parsed_doc = parser.parse(
|
||||
openapi_document_path,
|
||||
enable_file_ref_resolution=(execution_settings.enable_file_ref_resolution if execution_settings else False),
|
||||
enable_http_ref_resolution=(execution_settings.enable_http_ref_resolution if execution_settings else False),
|
||||
)
|
||||
if parsed_doc is None:
|
||||
raise FunctionExecutionException(f"Error parsing OpenAPI document: {openapi_document_path}")
|
||||
|
||||
parser = OpenApiParser()
|
||||
operations = parser.create_rest_api_operations(parsed_doc, execution_settings=execution_settings)
|
||||
|
||||
global_security_requirements = parsed_doc.get("security", [])
|
||||
|
||||
auth_callback = None
|
||||
if execution_settings and execution_settings.auth_callback:
|
||||
auth_callback = execution_settings.auth_callback
|
||||
|
||||
openapi_runner = OpenApiRunner(
|
||||
parsed_openapi_document=parsed_doc,
|
||||
auth_callback=auth_callback,
|
||||
http_client=execution_settings.http_client if execution_settings else None,
|
||||
enable_dynamic_payload=execution_settings.enable_dynamic_payload if execution_settings else True,
|
||||
enable_payload_namespacing=execution_settings.enable_payload_namespacing if execution_settings else False,
|
||||
server_url_validation_options=ServerUrlValidationOptions(
|
||||
allowed_base_urls=execution_settings.server_url_validation_allowed_base_urls,
|
||||
allow_private_network_access=execution_settings.allow_private_network_access,
|
||||
)
|
||||
if execution_settings
|
||||
else None,
|
||||
)
|
||||
|
||||
functions = []
|
||||
for operation in operations.values():
|
||||
try:
|
||||
kernel_function = _create_function_from_operation(
|
||||
openapi_runner,
|
||||
operation,
|
||||
plugin_name,
|
||||
execution_parameters=execution_settings,
|
||||
security=global_security_requirements,
|
||||
)
|
||||
functions.append(kernel_function)
|
||||
operation.freeze()
|
||||
except Exception as ex:
|
||||
error_msg = f"Error while registering Rest function {plugin_name}.{operation.id}: {ex}"
|
||||
logger.error(error_msg)
|
||||
raise FunctionExecutionException(error_msg) from ex
|
||||
|
||||
return functions
|
||||
|
||||
|
||||
@experimental
|
||||
def _create_function_from_operation(
|
||||
runner: OpenApiRunner,
|
||||
operation: RestApiOperation,
|
||||
plugin_name: str | None = None,
|
||||
execution_parameters: "OpenAPIFunctionExecutionParameters | None" = None,
|
||||
document_uri: str | None = None,
|
||||
security: list[RestApiSecurityRequirement] | None = None,
|
||||
) -> KernelFunctionFromMethod:
|
||||
logger.info(f"Registering OpenAPI operation: {plugin_name}.{operation.id}")
|
||||
|
||||
rest_operation_params: list[RestApiParameter] = operation.get_parameters(
|
||||
operation=operation,
|
||||
add_payload_params_from_metadata=getattr(execution_parameters, "enable_dynamic_payload", True),
|
||||
enable_payload_spacing=getattr(execution_parameters, "enable_payload_namespacing", False),
|
||||
)
|
||||
|
||||
@kernel_function(
|
||||
description=operation.summary if operation.summary else operation.description,
|
||||
name=operation.id,
|
||||
)
|
||||
async def run_openapi_operation(
|
||||
**kwargs: dict[str, Any],
|
||||
) -> str:
|
||||
try:
|
||||
kernel_arguments = KernelArguments()
|
||||
|
||||
for parameter in rest_operation_params:
|
||||
if parameter.alternative_name and parameter.alternative_name in kwargs:
|
||||
value = kwargs[parameter.alternative_name]
|
||||
if value is not None:
|
||||
kernel_arguments[parameter.name] = value
|
||||
continue
|
||||
|
||||
if parameter.name in kwargs:
|
||||
value = kwargs[parameter.name]
|
||||
if value is not None:
|
||||
kernel_arguments[parameter.name] = value
|
||||
continue
|
||||
|
||||
if parameter.is_required:
|
||||
raise FunctionExecutionException(
|
||||
f"No variable found in context to use as an argument for the "
|
||||
f"`{parameter.name}` parameter of the `{plugin_name}.{operation.id}` REST function."
|
||||
)
|
||||
|
||||
options = RestApiRunOptions(
|
||||
server_url_override=(
|
||||
execution_parameters.server_url_override
|
||||
if execution_parameters and execution_parameters.server_url_override is not None
|
||||
else None
|
||||
),
|
||||
api_host_url=Uri(document_uri).get_left_part() if document_uri is not None else None,
|
||||
timeout=execution_parameters.timeout
|
||||
if execution_parameters and execution_parameters.timeout is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
return await runner.run_operation(operation, kernel_arguments, options)
|
||||
except Exception as e:
|
||||
logger.error(f"Error running OpenAPI operation: {operation.id}", exc_info=True)
|
||||
raise FunctionExecutionException(f"Error running OpenAPI operation: {operation.id}") from e
|
||||
|
||||
parameters: list[KernelParameterMetadata] = [
|
||||
KernelParameterMetadata(
|
||||
name=p.alternative_name or p.name,
|
||||
description=f"{p.description or p.name}",
|
||||
default_value=p.default_value or "",
|
||||
is_required=p.is_required,
|
||||
type_=p.type if p.type is not None else TYPE_MAPPING.get(p.type, "object"),
|
||||
schema_data=(
|
||||
p.schema
|
||||
if p.schema is not None and isinstance(p.schema, dict)
|
||||
else {"type": f"{p.type}"}
|
||||
if p.type
|
||||
else None
|
||||
),
|
||||
)
|
||||
for p in rest_operation_params
|
||||
]
|
||||
|
||||
return_parameter = operation.get_default_return_parameter()
|
||||
|
||||
additional_metadata = {
|
||||
OperationExtensions.METHOD_KEY.value: operation.method.upper(),
|
||||
OperationExtensions.OPERATION_KEY.value: operation,
|
||||
OperationExtensions.SERVER_URLS_KEY.value: (
|
||||
[operation.servers[0]["url"]]
|
||||
if operation.servers and len(operation.servers) > 0 and operation.servers[0]["url"]
|
||||
else []
|
||||
),
|
||||
}
|
||||
|
||||
if security is not None:
|
||||
additional_metadata[OperationExtensions.SECURITY_KEY.value] = security
|
||||
|
||||
return KernelFunctionFromMethod(
|
||||
method=run_openapi_operation,
|
||||
plugin_name=plugin_name,
|
||||
parameters=parameters,
|
||||
return_parameter=return_parameter,
|
||||
additional_metadata=additional_metadata,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,189 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from inspect import isawaitable
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
from openapi_core import Spec
|
||||
|
||||
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_payload import RestApiPayload
|
||||
from semantic_kernel.connectors.openapi_plugin.models.rest_api_run_options import RestApiRunOptions
|
||||
from semantic_kernel.connectors.openapi_plugin.server_url_validator import (
|
||||
ServerUrlValidationOptions,
|
||||
validate_server_url,
|
||||
)
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
||||
from semantic_kernel.functions.kernel_arguments import KernelArguments
|
||||
from semantic_kernel.utils.feature_stage_decorator import experimental
|
||||
from semantic_kernel.utils.telemetry.user_agent import APP_INFO, prepend_semantic_kernel_to_user_agent
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@experimental
|
||||
class OpenApiRunner:
|
||||
"""The OpenApiRunner that runs the operations defined in the OpenAPI manifest."""
|
||||
|
||||
payload_argument_name = "payload"
|
||||
media_type_application_json = "application/json"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parsed_openapi_document: Mapping[str, str],
|
||||
auth_callback: Callable[..., dict[str, str] | Awaitable[dict[str, str]]] | None = None,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
enable_dynamic_payload: bool = True,
|
||||
enable_payload_namespacing: bool = False,
|
||||
server_url_validation_options: ServerUrlValidationOptions | None = None,
|
||||
):
|
||||
"""Initialize the OpenApiRunner."""
|
||||
self.spec = Spec.from_dict(parsed_openapi_document) # type: ignore
|
||||
self.auth_callback = auth_callback
|
||||
self.http_client = http_client
|
||||
self.enable_dynamic_payload = enable_dynamic_payload
|
||||
self.enable_payload_namespacing = enable_payload_namespacing
|
||||
self.server_url_validation_options = server_url_validation_options or ServerUrlValidationOptions()
|
||||
|
||||
def build_full_url(self, base_url, query_string):
|
||||
"""Build the full URL."""
|
||||
url_parts = list(urlparse(base_url))
|
||||
url_parts[4] = query_string
|
||||
return urlunparse(url_parts)
|
||||
|
||||
def build_operation_url(
|
||||
self, operation: RestApiOperation, arguments: KernelArguments, server_url_override=None, api_host_url=None
|
||||
):
|
||||
"""Build the operation URL."""
|
||||
url = operation.build_operation_url(arguments, server_url_override, api_host_url)
|
||||
return self.build_full_url(url, operation.build_query_string(arguments))
|
||||
|
||||
def build_json_payload(self, payload_metadata: RestApiPayload, arguments: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Build the JSON payload."""
|
||||
if self.enable_dynamic_payload:
|
||||
if payload_metadata is None:
|
||||
raise FunctionExecutionException(
|
||||
"Payload can't be built dynamically due to the missing payload metadata."
|
||||
)
|
||||
|
||||
payload = self.build_json_object(payload_metadata.properties, arguments)
|
||||
content = json.dumps(payload)
|
||||
return content, payload_metadata.media_type
|
||||
|
||||
argument = arguments.get(self.payload_argument_name)
|
||||
if not isinstance(argument, str):
|
||||
raise FunctionExecutionException(f"No payload is provided by the argument '{self.payload_argument_name}'.")
|
||||
|
||||
return argument, argument
|
||||
|
||||
def build_json_object(self, properties, arguments, property_namespace=None):
|
||||
"""Build the JSON payload object."""
|
||||
result = {}
|
||||
|
||||
for property_metadata in properties:
|
||||
argument_name = self.get_argument_name_for_payload(property_metadata.name, property_namespace)
|
||||
if property_metadata.type == "object":
|
||||
node = self.build_json_object(property_metadata.properties, arguments, argument_name)
|
||||
result[property_metadata.name] = node
|
||||
continue
|
||||
property_value = arguments.get(argument_name)
|
||||
if property_value is not None:
|
||||
result[property_metadata.name] = property_value
|
||||
continue
|
||||
if property_metadata.is_required:
|
||||
raise FunctionExecutionException(
|
||||
f"No argument is found for the '{property_metadata.name}' payload property."
|
||||
)
|
||||
return result
|
||||
|
||||
def build_operation_payload(
|
||||
self, operation: RestApiOperation, arguments: KernelArguments
|
||||
) -> tuple[str, str] | tuple[None, None]:
|
||||
"""Build the operation payload."""
|
||||
if operation.request_body is None and self.payload_argument_name not in arguments:
|
||||
return None, None
|
||||
|
||||
if operation.request_body is not None:
|
||||
return self.build_json_payload(operation.request_body, arguments)
|
||||
|
||||
return None, None
|
||||
|
||||
def get_argument_name_for_payload(self, property_name, property_namespace=None):
|
||||
"""Get argument name for the payload."""
|
||||
if not self.enable_payload_namespacing:
|
||||
return property_name
|
||||
return f"{property_namespace}.{property_name}" if property_namespace else property_name
|
||||
|
||||
def _get_first_response_media_type(self, responses: OrderedDict[str, RestApiExpectedResponse] | None) -> str:
|
||||
if responses:
|
||||
first_response = next(iter(responses.values()))
|
||||
return first_response.media_type if first_response.media_type else self.media_type_application_json
|
||||
return self.media_type_application_json
|
||||
|
||||
async def run_operation(
|
||||
self,
|
||||
operation: RestApiOperation,
|
||||
arguments: KernelArguments | None = None,
|
||||
options: RestApiRunOptions | None = None,
|
||||
) -> str:
|
||||
"""Runs the operation defined in the OpenAPI manifest."""
|
||||
if not arguments:
|
||||
arguments = KernelArguments()
|
||||
url = self.build_operation_url(
|
||||
operation=operation,
|
||||
arguments=arguments,
|
||||
server_url_override=options.server_url_override if options else None,
|
||||
api_host_url=options.api_host_url if options else None,
|
||||
)
|
||||
await validate_server_url(url, self.server_url_validation_options)
|
||||
headers = operation.build_headers(arguments=arguments)
|
||||
payload, _ = self.build_operation_payload(operation=operation, arguments=arguments)
|
||||
|
||||
if self.auth_callback:
|
||||
headers_update = self.auth_callback(**headers)
|
||||
if isawaitable(headers_update):
|
||||
headers_update = await headers_update
|
||||
# at this point, headers_update is a valid dictionary
|
||||
headers.update(headers_update) # type: ignore
|
||||
|
||||
if APP_INFO:
|
||||
headers.update(APP_INFO)
|
||||
headers = prepend_semantic_kernel_to_user_agent(headers)
|
||||
|
||||
if "Content-Type" not in headers:
|
||||
responses = (
|
||||
operation.responses
|
||||
if isinstance(operation.responses, OrderedDict)
|
||||
else OrderedDict(operation.responses or {})
|
||||
)
|
||||
headers["Content-Type"] = self._get_first_response_media_type(responses)
|
||||
|
||||
timeout = options.timeout if options and hasattr(options, "timeout") and options.timeout is not None else None
|
||||
|
||||
async def fetch():
|
||||
async def make_request(client: httpx.AsyncClient):
|
||||
merged_headers = client.headers.copy()
|
||||
merged_headers.update(headers)
|
||||
response = await client.request(
|
||||
method=operation.method,
|
||||
url=url,
|
||||
headers=merged_headers,
|
||||
json=json.loads(payload) if payload else None,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
if hasattr(self, "http_client") and self.http_client is not None:
|
||||
return await make_request(self.http_client)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
return await make_request(client)
|
||||
|
||||
return await fetch()
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
|
||||
class OperationSelectionPredicateContext:
|
||||
"""The context for the operation selection predicate."""
|
||||
|
||||
def __init__(self, operation_id: str, path: str, method: str, description: str | None = None):
|
||||
"""Initialize the operation selection predicate context."""
|
||||
self.operation_id = operation_id
|
||||
self.path = path
|
||||
self.method = method
|
||||
self.description = description
|
||||
@@ -0,0 +1,241 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import Any
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from semantic_kernel.exceptions.function_exceptions import FunctionExecutionException
|
||||
from semantic_kernel.kernel_pydantic import KernelBaseModel
|
||||
|
||||
DnsResolver = Callable[[str], Awaitable[Sequence[str | ipaddress.IPv4Address | ipaddress.IPv6Address]]]
|
||||
|
||||
DEFAULT_ALLOWED_SCHEME = "https"
|
||||
|
||||
|
||||
class ServerUrlValidationOptions(KernelBaseModel):
|
||||
"""Options for validating OpenAPI operation request URLs."""
|
||||
|
||||
allowed_base_urls: list[str] = Field(default_factory=list)
|
||||
allow_private_network_access: bool = False
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Validate configured allowed base URLs."""
|
||||
for allowed_base_url in self.allowed_base_urls:
|
||||
_parse_absolute_url(allowed_base_url, option_name="allowed_base_urls")
|
||||
|
||||
|
||||
async def validate_server_url(
|
||||
url: str,
|
||||
options: ServerUrlValidationOptions | None = None,
|
||||
dns_resolver: DnsResolver | None = None,
|
||||
) -> None:
|
||||
"""Validate a fully resolved OpenAPI operation URL against the supplied policy."""
|
||||
options = options or ServerUrlValidationOptions()
|
||||
try:
|
||||
parsed_url = _parse_absolute_url(url)
|
||||
except ValueError as exc:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI '{url}' is not allowed because it is not a valid absolute URI."
|
||||
) from exc
|
||||
|
||||
if _matches_allowed_base_url(parsed_url, options.allowed_base_urls):
|
||||
return
|
||||
|
||||
if options.allowed_base_urls:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI '{url}' is not allowed. It does not match any of the allowed base URLs."
|
||||
)
|
||||
|
||||
if parsed_url.scheme.lower() != DEFAULT_ALLOWED_SCHEME:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI scheme '{parsed_url.scheme}' is not allowed. "
|
||||
f"Only '{DEFAULT_ALLOWED_SCHEME}' is permitted by default. "
|
||||
"To allow this URL, add it to server_url_validation_allowed_base_urls."
|
||||
)
|
||||
|
||||
if options.allow_private_network_access:
|
||||
return
|
||||
|
||||
await _ensure_public_host(parsed_url, dns_resolver)
|
||||
|
||||
|
||||
def try_categorize_non_public_address(
|
||||
address: str | ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> tuple[bool, str]:
|
||||
"""Return whether an IP address is non-public and the category when blocked."""
|
||||
ip_address = ipaddress.ip_address(address)
|
||||
|
||||
if isinstance(ip_address, ipaddress.IPv6Address) and ip_address.ipv4_mapped:
|
||||
ip_address = ip_address.ipv4_mapped
|
||||
|
||||
if isinstance(ip_address, ipaddress.IPv4Address):
|
||||
return _try_classify_ipv4(ip_address)
|
||||
|
||||
return _try_classify_ipv6(ip_address)
|
||||
|
||||
|
||||
def _parse_absolute_url(url: str, option_name: str = "url") -> ParseResult:
|
||||
parsed_url = urlparse(url)
|
||||
try:
|
||||
parsed_url.port
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Invalid {option_name}: {url}") from exc
|
||||
|
||||
if not parsed_url.scheme or not parsed_url.netloc or not parsed_url.hostname:
|
||||
raise ValueError(f"Invalid {option_name}: {url}")
|
||||
return parsed_url
|
||||
|
||||
|
||||
def _matches_allowed_base_url(url: ParseResult, allowed_base_urls: list[str]) -> bool:
|
||||
for allowed_base_url in allowed_base_urls:
|
||||
base_url = _parse_absolute_url(allowed_base_url, option_name="allowed_base_urls")
|
||||
if url.scheme.lower() != base_url.scheme.lower():
|
||||
continue
|
||||
if (url.hostname or "").lower() != (base_url.hostname or "").lower():
|
||||
continue
|
||||
if _effective_port(url) != _effective_port(base_url):
|
||||
continue
|
||||
if _matches_path_prefix(url.path, base_url.path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _effective_port(url: ParseResult) -> int | None:
|
||||
if url.port is not None:
|
||||
return url.port
|
||||
if url.scheme.lower() == "https":
|
||||
return 443
|
||||
if url.scheme.lower() == "http":
|
||||
return 80
|
||||
return None
|
||||
|
||||
|
||||
def _matches_path_prefix(url_path: str, base_path: str) -> bool:
|
||||
url_path = url_path or "/"
|
||||
base_path = base_path or "/"
|
||||
|
||||
if url_path.lower() == base_path.lower():
|
||||
return True
|
||||
|
||||
base_path_with_slash = base_path if base_path.endswith("/") else f"{base_path}/"
|
||||
return url_path.lower().startswith(base_path_with_slash.lower())
|
||||
|
||||
|
||||
async def _ensure_public_host(parsed_url: ParseResult, dns_resolver: DnsResolver | None) -> None:
|
||||
host = parsed_url.hostname
|
||||
if host is None:
|
||||
raise FunctionExecutionException(f"The request URI '{parsed_url.geturl()}' does not contain a valid host.")
|
||||
|
||||
try:
|
||||
ip_address = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
addresses = await _resolve_host(host, dns_resolver)
|
||||
else:
|
||||
_ensure_public_address(parsed_url.geturl(), ip_address)
|
||||
return
|
||||
|
||||
if not addresses:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI '{parsed_url.geturl()}' is not allowed: DNS resolution for host "
|
||||
f"'{host}' returned no addresses. The request is blocked as a precaution."
|
||||
)
|
||||
|
||||
for address in addresses:
|
||||
_ensure_public_address(parsed_url.geturl(), address)
|
||||
|
||||
|
||||
async def _resolve_host(
|
||||
host: str,
|
||||
dns_resolver: DnsResolver | None,
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
try:
|
||||
if dns_resolver:
|
||||
resolved_addresses = await dns_resolver(host)
|
||||
return [ipaddress.ip_address(address) for address in resolved_addresses]
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
addr_info = await loop.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI host '{host}' is not allowed: DNS resolution failed. "
|
||||
"The request is blocked as a precaution to prevent potential access to private network addresses."
|
||||
) from exc
|
||||
|
||||
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||
seen_addresses: set[str] = set()
|
||||
for family, _, _, _, sockaddr in addr_info:
|
||||
if family not in (socket.AF_INET, socket.AF_INET6):
|
||||
continue
|
||||
address = ipaddress.ip_address(sockaddr[0])
|
||||
address_string = str(address)
|
||||
if address_string not in seen_addresses:
|
||||
addresses.append(address)
|
||||
seen_addresses.add(address_string)
|
||||
return addresses
|
||||
|
||||
|
||||
def _ensure_public_address(url: str, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None:
|
||||
blocked, category = try_categorize_non_public_address(address)
|
||||
if blocked:
|
||||
raise FunctionExecutionException(
|
||||
f"The request URI '{url}' is not allowed: host resolves to a {category} address ({address}), "
|
||||
"which is blocked by default to prevent Server-Side Request Forgery (SSRF). "
|
||||
"To allow this URL, add it to server_url_validation_allowed_base_urls or set "
|
||||
"allow_private_network_access=True."
|
||||
)
|
||||
|
||||
|
||||
def _try_classify_ipv4(address: ipaddress.IPv4Address) -> tuple[bool, str]:
|
||||
b0, b1, b2, _ = address.packed
|
||||
|
||||
if b0 == 0:
|
||||
return True, "unspecified"
|
||||
if b0 == 10:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 127:
|
||||
return True, "loopback"
|
||||
if b0 == 169 and b1 == 254:
|
||||
return True, "link-local"
|
||||
if b0 == 172 and 16 <= b1 <= 31:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 192 and b1 == 168:
|
||||
return True, "private (RFC1918)"
|
||||
if b0 == 100 and 64 <= b1 <= 127:
|
||||
return True, "carrier-grade NAT"
|
||||
if b0 == 198 and b1 in (18, 19):
|
||||
return True, "benchmarking"
|
||||
if b0 == 192 and b1 == 0 and b2 in (0, 2):
|
||||
return True, "reserved"
|
||||
if b0 == 198 and b1 == 51 and b2 == 100:
|
||||
return True, "reserved"
|
||||
if b0 == 203 and b1 == 0 and b2 == 113:
|
||||
return True, "reserved"
|
||||
if 224 <= b0 <= 239:
|
||||
return True, "multicast"
|
||||
if b0 >= 240:
|
||||
return True, "reserved"
|
||||
|
||||
return False, ""
|
||||
|
||||
|
||||
def _try_classify_ipv6(address: ipaddress.IPv6Address) -> tuple[bool, str]:
|
||||
if address.is_loopback:
|
||||
return True, "loopback"
|
||||
if address.is_unspecified:
|
||||
return True, "unspecified"
|
||||
if address.is_link_local:
|
||||
return True, "link-local"
|
||||
if address in ipaddress.ip_network("fc00::/7"):
|
||||
return True, "private (IPv6 ULA)"
|
||||
if address.is_multicast:
|
||||
return True, "multicast"
|
||||
if address in ipaddress.ip_network("2001:db8::/32"):
|
||||
return True, "reserved"
|
||||
|
||||
return False, ""
|
||||
Reference in New Issue
Block a user