chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -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
@@ -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
@@ -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}"
@@ -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()
@@ -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, ""