diff --git a/python/samples/demos/process_with_dapr/fastapi_app.py b/python/samples/demos/process_with_dapr/fastapi_app.py index d34f96503acb..263356a8bcea 100644 --- a/python/samples/demos/process_with_dapr/fastapi_app.py +++ b/python/samples/demos/process_with_dapr/fastapi_app.py @@ -65,8 +65,8 @@ async def start_process(process_id: str): process_id=process_id, ) return JSONResponse(content={"processId": process_id}, status_code=200) - except Exception as e: - return JSONResponse(content={"error": str(e)}, status_code=500) + except Exception: + return JSONResponse(content={"error": "Error starting process"}, status_code=500) if __name__ == "__main__": diff --git a/python/samples/demos/process_with_dapr/flask_app.py b/python/samples/demos/process_with_dapr/flask_app.py index bd7483e1854f..3e86510f4a2b 100644 --- a/python/samples/demos/process_with_dapr/flask_app.py +++ b/python/samples/demos/process_with_dapr/flask_app.py @@ -55,9 +55,8 @@ def start_process(process_id): ) return jsonify({"processId": process_id}), 200 - except Exception as e: - logging.exception("Error starting process") - return jsonify({"error": str(e)}), 500 + except Exception: + return jsonify({"error": "Error starting process"}), 500 # Run application diff --git a/python/semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_tracing.py b/python/semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_tracing.py index 03d0c6c4f53a..e326ed1d9b6b 100644 --- a/python/semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_tracing.py +++ b/python/semantic_kernel/connectors/ai/azure_ai_inference/services/azure_ai_inference_tracing.py @@ -35,7 +35,7 @@ def __enter__(self) -> None: self.diagnostics_settings.enable_otel_diagnostics or self.diagnostics_settings.enable_otel_diagnostics_sensitive ): - AIInferenceInstrumentor().instrument( + AIInferenceInstrumentor().instrument( # type: ignore enable_content_recording=self.diagnostics_settings.enable_otel_diagnostics_sensitive ) diff --git a/python/semantic_kernel/connectors/openai_plugin/openai_authentication_config.py b/python/semantic_kernel/connectors/openai_plugin/openai_authentication_config.py index fddb9a722a1a..e9025334d159 100644 --- a/python/semantic_kernel/connectors/openai_plugin/openai_authentication_config.py +++ b/python/semantic_kernel/connectors/openai_plugin/openai_authentication_config.py @@ -4,10 +4,12 @@ from enum import Enum from pydantic import HttpUrl +from typing_extensions import deprecated from semantic_kernel.kernel_pydantic import KernelBaseModel +@deprecated("The `OpenAIAuthenticationType` class is deprecated; use the `OpenAPI` plugin instead.", category=None) class OpenAIAuthenticationType(str, Enum): """OpenAI authentication types.""" @@ -15,6 +17,7 @@ class OpenAIAuthenticationType(str, Enum): NoneType = "none" +@deprecated("The `OpenAIAuthenticationType` class is deprecated; use the `OpenAPI` plugin instead.", category=None) class OpenAIAuthorizationType(str, Enum): """OpenAI authorization types.""" @@ -22,6 +25,7 @@ class OpenAIAuthorizationType(str, Enum): Basic = "Basic" +@deprecated("The `OpenAIAuthenticationConfig` class is deprecated; use the `OpenAPI` plugin instead.", category=None) class OpenAIAuthenticationConfig(KernelBaseModel): """OpenAI authentication configuration.""" diff --git a/python/semantic_kernel/connectors/openai_plugin/openai_function_execution_parameters.py b/python/semantic_kernel/connectors/openai_plugin/openai_function_execution_parameters.py index 0638e820fbaf..b590f3f28898 100644 --- a/python/semantic_kernel/connectors/openai_plugin/openai_function_execution_parameters.py +++ b/python/semantic_kernel/connectors/openai_plugin/openai_function_execution_parameters.py @@ -2,16 +2,47 @@ from collections.abc import Awaitable, Callable -from typing import Any +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse -from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import ( - OpenAPIFunctionExecutionParameters, -) +import httpx +from pydantic import Field +from typing_extensions import deprecated + +from semantic_kernel.kernel_pydantic import KernelBaseModel + +if TYPE_CHECKING: + from semantic_kernel.connectors.openapi_plugin import ( + OperationSelectionPredicateContext, + ) OpenAIAuthCallbackType = Callable[..., Awaitable[Any]] -class OpenAIFunctionExecutionParameters(OpenAPIFunctionExecutionParameters): +@deprecated( + "The `OpenAIFunctionExecutionParameters` class is deprecated; use the `OpenAPI` plugin instead.", category=None +) +class OpenAIFunctionExecutionParameters(KernelBaseModel): """OpenAI function execution parameters.""" auth_callback: OpenAIAuthCallbackType | None = None + http_client: httpx.AsyncClient | 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 + + def model_post_init(self, __context: Any) -> None: + """Post initialization method for the model.""" + 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}") + + if not self.user_agent: + self.user_agent = HTTP_USER_AGENT diff --git a/python/semantic_kernel/connectors/openai_plugin/openai_utils.py b/python/semantic_kernel/connectors/openai_plugin/openai_utils.py index 44ce20f127ce..9e95374f7f5a 100644 --- a/python/semantic_kernel/connectors/openai_plugin/openai_utils.py +++ b/python/semantic_kernel/connectors/openai_plugin/openai_utils.py @@ -4,11 +4,14 @@ import logging from typing import Any +from typing_extensions import deprecated + from semantic_kernel.exceptions.function_exceptions import PluginInitializationError logger: logging.Logger = logging.getLogger(__name__) +@deprecated("The `OpenAIUtils` class is deprecated; use the `OpenAPI` plugin instead.", category=None) class OpenAIUtils: """Utility functions for OpenAI plugins.""" diff --git a/python/semantic_kernel/connectors/openapi_plugin/__init__.py b/python/semantic_kernel/connectors/openapi_plugin/__init__.py index 8ad89fbd5635..875c5155d301 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/__init__.py +++ b/python/semantic_kernel/connectors/openapi_plugin/__init__.py @@ -3,5 +3,9 @@ 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, +) -__all__ = ["OpenAPIFunctionExecutionParameters"] +__all__ = ["OpenAPIFunctionExecutionParameters", "OpenApiParser", "OperationSelectionPredicateContext"] diff --git a/python/semantic_kernel/connectors/openapi_plugin/const.py b/python/semantic_kernel/connectors/openapi_plugin/const.py new file mode 100644 index 000000000000..ac4cebb1aeab --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/const.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft. All rights reserved. + + +from enum import Enum + +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +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" diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_expected_response.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_expected_response.py similarity index 70% rename from python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_expected_response.py rename to python/semantic_kernel/connectors/openapi_plugin/models/rest_api_expected_response.py index f5669ecb081d..5fee34f9e2c0 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_expected_response.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_expected_response.py @@ -5,11 +5,11 @@ @experimental_class -class RestApiOperationExpectedResponse: - """RestApiOperationExpectedResponse.""" +class RestApiExpectedResponse: + """RestApiExpectedResponse.""" def __init__(self, description: str, media_type: str, schema: dict[str, str] | None = None): - """Initialize the RestApiOperationExpectedResponse.""" + """Initialize the RestApiExpectedResponse.""" self.description = description self.media_type = media_type self.schema = schema diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flow.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flow.py new file mode 100644 index 000000000000..2de8cc4162ec --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flow.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft. All rights reserved. + +from dataclasses import dataclass + +from semantic_kernel.utils.experimental_decorator import experimental_class + + +@experimental_class +@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 diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flows.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flows.py new file mode 100644 index 000000000000..f739b757cb95 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_oauth_flows.py @@ -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.experimental_decorator import experimental_class + + +@experimental_class +@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 diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py index 7ab80e300405..f6150e70a0a7 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation.py @@ -2,22 +2,23 @@ import re from typing import Any, Final -from urllib.parse import ParseResult, urlencode, urljoin, urlparse, urlunparse +from urllib.parse import ParseResult, ParseResultBytes, urlencode, urljoin, urlparse, urlunparse -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import RestApiOperationParameter -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_location import ( - RestApiOperationParameterLocation, +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_operation_parameter_style import ( - RestApiOperationParameterStyle, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_style import ( + RestApiParameterStyle, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload_property import ( - RestApiOperationPayloadProperty, +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.experimental_decorator import experimental_class @@ -51,24 +52,144 @@ def __init__( self, id: str, method: str, - server_url: str | ParseResult, + servers: list[dict[str, Any]], path: str, summary: str | None = None, description: str | None = None, - params: list["RestApiOperationParameter"] | None = None, - request_body: "RestApiOperationPayload | None" = None, - responses: dict[str, "RestApiOperationExpectedResponse"] | 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.server_url = urlparse(server_url) if isinstance(server_url, str) else server_url - self.path = path - self.summary = summary - self.description = description - self.parameters = params if params else [] - self.request_body = request_body - self.responses = responses + 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.""" @@ -81,7 +202,7 @@ 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 == RestApiOperationParameterLocation.HEADER] + parameters = [p for p in self.parameters if p.location == RestApiParameterLocation.HEADER] for parameter in parameters: argument = arguments.get(parameter.name) @@ -102,30 +223,62 @@ def build_operation_url(self, arguments, server_url_override=None, api_host_url= """Build the URL for the operation.""" server_url = self.get_server_url(server_url_override, api_host_url) path = self.build_path(self.path, arguments) - return urljoin(server_url.geturl(), path.lstrip("/")) + try: + return 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 - def get_server_url(self, server_url_override=None, api_host_url=None): + def get_server_url(self, server_url_override=None, api_host_url=None, arguments=None): """Get the server URL for the operation.""" - if server_url_override is not None and server_url_override.geturl() != b"": + 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) + if argument_name in arguments: + value = arguments[argument_name] + server_url_string = server_url_string.replace(f"{{{variable_name}}}", value) + elif "default" in variable_def and variable_def["default"] is not None: + # Use the default value if no argument is provided + value = variable_def["default"] + server_url_string = server_url_string.replace(f"{{{variable_name}}}", value) + 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}'." + ) + elif self.server_url: + server_url_string = self.server_url + elif api_host_url is not None: + server_url_string = api_host_url else: - server_url_string = ( - self.server_url.geturl() - if self.server_url - else api_host_url.geturl() - if api_host_url - else self._raise_invalid_operation_exception() - ) + raise FunctionExecutionException(f"No valid server URL for operation {self.id}") - # make sure the base URL ends with a trailing slash + # Ensure the base URL ends with a trailing slash if not server_url_string.endswith("/"): server_url_string += "/" - return urlparse(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 == RestApiOperationParameterLocation.PATH] + 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: @@ -141,7 +294,7 @@ def build_path(self, path_template: str, arguments: dict[str, Any]) -> str: 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 == RestApiOperationParameterLocation.QUERY] + 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: @@ -163,7 +316,7 @@ def get_parameters( operation: "RestApiOperation", add_payload_params_from_metadata: bool = True, enable_payload_spacing: bool = False, - ) -> list["RestApiOperationParameter"]: + ) -> 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: @@ -180,9 +333,9 @@ def get_parameters( return params - def create_payload_artificial_parameter(self, operation: "RestApiOperation") -> "RestApiOperationParameter": + def create_payload_artificial_parameter(self, operation: "RestApiOperation") -> "RestApiParameter": """Create an artificial parameter for the REST API request body.""" - return RestApiOperationParameter( + return RestApiParameter( name=self.PAYLOAD_ARGUMENT_NAME, type=( "string" @@ -191,54 +344,52 @@ def create_payload_artificial_parameter(self, operation: "RestApiOperation") -> else "object" ), is_required=True, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + 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) -> "RestApiOperationParameter": + def create_content_type_artificial_parameter(self) -> "RestApiParameter": """Create an artificial parameter for the content type of the REST API request body.""" - return RestApiOperationParameter( + return RestApiParameter( name=self.CONTENT_TYPE_ARGUMENT_NAME, type="string", is_required=False, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="Content type of REST API request body.", ) - def _get_property_name( - self, property: RestApiOperationPayloadProperty, root_property_name: bool, enable_namespacing: bool - ): + 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["RestApiOperationPayloadProperty"], + properties: list["RestApiPayloadProperty"], enable_namespacing: bool = False, root_property_name: bool | None = None, - ) -> list["RestApiOperationParameter"]: - parameters: list[RestApiOperationParameter] = [] + ) -> 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( - RestApiOperationParameter( + RestApiParameter( name=parameter_name, type=property.type, is_required=property.is_required, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + 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, RestApiOperationPayloadProperty): + if isinstance(property.properties, RestApiPayloadProperty): nested_properties = [property.properties] else: nested_properties = property.properties @@ -269,8 +420,8 @@ def get_payload_parameters( ] def get_default_response( - self, responses: dict[str, RestApiOperationExpectedResponse], preferred_responses: list[str] - ) -> RestApiOperationExpectedResponse | None: + 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. diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter.py deleted file mode 100644 index 0f8745e08f2e..000000000000 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter.py +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Any - -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, -) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_location import ( - RestApiOperationParameterLocation, -) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_style import ( - RestApiOperationParameterStyle, -) -from semantic_kernel.utils.experimental_decorator import experimental_class - - -@experimental_class -class RestApiOperationParameter: - """RestApiOperationParameter.""" - - def __init__( - self, - name: str, - type: str, - location: RestApiOperationParameterLocation, - style: RestApiOperationParameterStyle | 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: RestApiOperationExpectedResponse | None = None, - ): - """Initialize the RestApiOperationParameter.""" - 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 diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload.py deleted file mode 100644 index 6734114f28a2..000000000000 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload.py +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload_property import ( - RestApiOperationPayloadProperty, -) -from semantic_kernel.utils.experimental_decorator import experimental_class - - -@experimental_class -class RestApiOperationPayload: - """RestApiOperationPayload.""" - - def __init__( - self, - media_type: str, - properties: list["RestApiOperationPayloadProperty"], - description: str | None = None, - schema: str | None = None, - ): - """Initialize the RestApiOperationPayload.""" - self.media_type = media_type - self.properties = properties - self.description = description - self.schema = schema diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload_property.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload_property.py deleted file mode 100644 index ab0ee15f3e9d..000000000000 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_payload_property.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from typing import Any - -from semantic_kernel.utils.experimental_decorator import experimental_class - - -@experimental_class -class RestApiOperationPayloadProperty: - """RestApiOperationPayloadProperty.""" - - def __init__( - self, - name: str, - type: str, - properties: "RestApiOperationPayloadProperty", - description: str | None = None, - is_required: bool = False, - default_value: Any | None = None, - schema: str | None = None, - ): - """Initialize the RestApiOperationPayloadProperty.""" - self.name = name - self.type = type - self.properties = properties - self.description = description - self.is_required = is_required - self.default_value = default_value - self.schema = schema diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter.py new file mode 100644 index 000000000000..def469cbeadf --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter.py @@ -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.experimental_decorator import experimental_class + + +@experimental_class +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 diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_location.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_location.py similarity index 71% rename from python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_location.py rename to python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_location.py index f1d7b68e2f0a..25da836bd3ce 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_location.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_location.py @@ -6,8 +6,8 @@ @experimental_class -class RestApiOperationParameterLocation(Enum): - """The location of the REST API operation parameter.""" +class RestApiParameterLocation(Enum): + """The location of the REST API parameter.""" PATH = "path" QUERY = "query" diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_style.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_style.py similarity index 69% rename from python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_style.py rename to python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_style.py index c76f9e3a8847..a5db1b921f6f 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_parameter_style.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_parameter_style.py @@ -6,7 +6,7 @@ @experimental_class -class RestApiOperationParameterStyle(Enum): - """RestApiOperationParameterStyle.""" +class RestApiParameterStyle(Enum): + """RestApiParameterStyle.""" SIMPLE = "simple" diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload.py new file mode 100644 index 000000000000..21c7cb288500 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload.py @@ -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.experimental_decorator import experimental_class + + +@experimental_class +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 diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload_property.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload_property.py new file mode 100644 index 000000000000..455609fdf927 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_payload_property.py @@ -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.experimental_decorator import experimental_class + + +@experimental_class +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 diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_run_options.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_run_options.py similarity index 92% rename from python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_run_options.py rename to python/semantic_kernel/connectors/openapi_plugin/models/rest_api_run_options.py index 332a446bf609..78ce7a760ca7 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_operation_run_options.py +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_run_options.py @@ -4,7 +4,7 @@ @experimental_class -class RestApiOperationRunOptions: +class RestApiRunOptions: """The options for running the REST API operation.""" def __init__(self, server_url_override=None, api_host_url=None) -> None: diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_requirement.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_requirement.py new file mode 100644 index 000000000000..78a07ace5da6 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_requirement.py @@ -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.experimental_decorator import experimental_class + + +@experimental_class +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) diff --git a/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_scheme.py b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_scheme.py new file mode 100644 index 000000000000..c57669b7f121 --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/models/rest_api_security_scheme.py @@ -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.experimental_decorator import experimental_class + + +@experimental_class +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 diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py index dd014a97e55c..b5ff0ec2ae4e 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_function_execution_parameters.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import Awaitable, Callable -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import urlparse import httpx @@ -10,6 +10,11 @@ from semantic_kernel.kernel_pydantic import KernelBaseModel from semantic_kernel.utils.experimental_decorator import experimental_class +if TYPE_CHECKING: + from semantic_kernel.connectors.openapi_plugin import ( + OperationSelectionPredicateContext, + ) + AuthCallbackType = Callable[..., Awaitable[Any]] @@ -24,7 +29,8 @@ class OpenAPIFunctionExecutionParameters(KernelBaseModel): user_agent: str | None = None enable_dynamic_payload: bool = True enable_payload_namespacing: bool = False - operations_to_exclude: list[str] = Field(default_factory=list) + operations_to_exclude: list[str] = Field(default_factory=list, description="The operationId(s) to exclude") + operation_selection_predicate: Callable[["OperationSelectionPredicateContext"], bool] | None = None def model_post_init(self, __context: Any) -> None: """Post initialization method for the model.""" diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py index bc195dec1bef..c5823c7d559f 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_manager.py @@ -4,9 +4,11 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse +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_operation_parameter import RestApiOperationParameter -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_run_options import RestApiOperationRunOptions +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 @@ -32,24 +34,41 @@ @experimental_function def create_functions_from_openapi( plugin_name: str, - openapi_document_path: str, + openapi_document_path: str | None = None, + openapi_parsed_spec: dict[str, Any] | None = None, execution_settings: "OpenAIFunctionExecutionParameters | 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. + 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) + if parsed_doc is None: + raise FunctionExecutionException(f"Error parsing OpenAPI document: {openapi_document_path}") + parser = OpenApiParser() - if (parsed_doc := parser.parse(openapi_document_path)) is None: - raise FunctionExecutionException(f"Error parsing OpenAPI document: {openapi_document_path}") 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 @@ -62,10 +81,24 @@ def create_functions_from_openapi( enable_payload_namespacing=execution_settings.enable_payload_namespacing if execution_settings else False, ) - return [ - _create_function_from_operation(openapi_runner, operation, plugin_name, execution_parameters=execution_settings) - for operation in operations.values() - ] + 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_function @@ -75,10 +108,11 @@ def _create_function_from_operation( plugin_name: str | None = None, execution_parameters: "OpenAIFunctionExecutionParameters | 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[RestApiOperationParameter] = operation.get_parameters( + 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), @@ -113,7 +147,7 @@ async def run_openapi_operation( f"`{parameter.name}` parameter of the `{plugin_name}.{operation.id}` REST function." ) - options = RestApiOperationRunOptions( + options = RestApiRunOptions( server_url_override=( urlparse(execution_parameters.server_url_override) if execution_parameters else None ), @@ -145,7 +179,18 @@ async def run_openapi_operation( return_parameter = operation.get_default_return_parameter() - additional_metadata = {"method": operation.method.upper()} + 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, diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py index 984f120e837b..a08276de387d 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py @@ -4,24 +4,24 @@ from collections import OrderedDict from collections.abc import Generator from typing import TYPE_CHECKING, Any, Final -from urllib.parse import urlparse from prance import ResolvingParser -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import RestApiOperationParameter -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_location import ( - RestApiOperationParameterLocation, +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_operation_payload import RestApiOperationPayload -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload_property import ( - RestApiOperationPayloadProperty, +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 -from semantic_kernel.utils.experimental_decorator import experimental_class if TYPE_CHECKING: from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import ( @@ -34,7 +34,6 @@ logger: logging.Logger = logging.getLogger(__name__) -@experimental_class class OpenApiParser: """NOTE: SK Python only supports the OpenAPI Spec >=3.0. @@ -61,7 +60,7 @@ def parse(self, openapi_document: str) -> Any | dict[str, Any] | None: def _parse_parameters(self, parameters: list[dict[str, Any]]): """Parse the parameters from the OpenAPI document.""" - result: list[RestApiOperationParameter] = [] + result: list[RestApiParameter] = [] for param in parameters: name: str = param["name"] if not param.get("in"): @@ -69,14 +68,14 @@ def _parse_parameters(self, parameters: list[dict[str, Any]]): 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 = RestApiOperationParameterLocation(param["in"]) + location = RestApiParameterLocation(param["in"]) description: str = 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( - RestApiOperationParameter( + RestApiParameter( name=name, type=schema.get("type", "string") if schema else "string", location=location, @@ -103,7 +102,7 @@ def _get_payload_properties(self, operation_id, schema, required_properties, lev for property_name, property_schema in schema.get("properties", {}).items(): default_value = property_schema.get("default", None) - property = RestApiOperationPayloadProperty( + property = RestApiPayloadProperty( name=property_name, type=property_schema.get("type", None), is_required=property_name in required_properties, @@ -119,7 +118,7 @@ def _get_payload_properties(self, operation_id, schema, required_properties, lev def _create_rest_api_operation_payload( self, operation_id: str, request_body: dict[str, Any] - ) -> RestApiOperationPayload | None: + ) -> RestApiPayload | None: if request_body is None or request_body.get("content") is None: return None @@ -135,16 +134,14 @@ def _create_rest_api_operation_payload( payload_properties = self._get_payload_properties( operation_id, media_type_metadata["schema"], media_type_metadata["schema"].get("required", set()) ) - return RestApiOperationPayload( + 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, RestApiOperationExpectedResponse], None, 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 @@ -154,60 +151,130 @@ def _create_response( description = response_value.get("description") or matching_schema.get("description", "") yield ( response_key, - RestApiOperationExpectedResponse( + 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: "OpenAIFunctionExecutionParameters | OpenAPIFunctionExecutionParameters | None" = None, ) -> dict[str, RestApiOperation]: - """Create the REST API Operations from the parsed OpenAPI document. + """Create REST API operations from the parsed OpenAPI document. Args: - parsed_document: The parsed OpenAPI document - execution_settings: The execution settings + parsed_document: The parsed OpenAPI document. + execution_settings: The execution settings. Returns: - A dictionary of RestApiOperation objects keyed by operationId + 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 = {} - base_url = "/" servers = parsed_document.get("servers", []) - base_url = servers[0].get("url") if servers else "/" if execution_settings and execution_settings.server_url_override: - base_url = 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 + server_urls = [] + 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() - - parameters = details.get("parameters", []) operationId = details.get("operationId", path + "_" + request_method) + summary = details.get("summary", None) description = details.get("description", None) + 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, - server_url=urlparse(base_url), + 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 diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py index 540ee6fc7845..8b15ddfa5222 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_runner.py @@ -11,12 +11,12 @@ import httpx from openapi_core import Spec -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_run_options import RestApiOperationRunOptions +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.exceptions.function_exceptions import FunctionExecutionException from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.utils.experimental_decorator import experimental_class @@ -60,9 +60,7 @@ def build_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: RestApiOperationPayload, arguments: dict[str, Any] - ) -> tuple[str, str]: + 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: @@ -118,9 +116,7 @@ def get_argument_name_for_payload(self, property_name, property_namespace=None): 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, RestApiOperationExpectedResponse] | None - ) -> str: + 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 @@ -130,7 +126,7 @@ async def run_operation( self, operation: RestApiOperation, arguments: KernelArguments | None = None, - options: RestApiOperationRunOptions | None = None, + options: RestApiRunOptions | None = None, ) -> str: """Runs the operation defined in the OpenAPI manifest.""" if not arguments: diff --git a/python/semantic_kernel/connectors/openapi_plugin/operation_selection_predicate_context.py b/python/semantic_kernel/connectors/openapi_plugin/operation_selection_predicate_context.py new file mode 100644 index 000000000000..8d8081668eee --- /dev/null +++ b/python/semantic_kernel/connectors/openapi_plugin/operation_selection_predicate_context.py @@ -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 diff --git a/python/semantic_kernel/functions/kernel_function_extension.py b/python/semantic_kernel/functions/kernel_function_extension.py index 361e821ac0c7..97f5cf527fce 100644 --- a/python/semantic_kernel/functions/kernel_function_extension.py +++ b/python/semantic_kernel/functions/kernel_function_extension.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Literal from pydantic import Field, field_validator +from typing_extensions import deprecated from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings from semantic_kernel.exceptions import KernelFunctionNotFoundError, KernelPluginNotFoundError @@ -206,17 +207,19 @@ def add_functions( def add_plugin_from_openapi( self, plugin_name: str, - openapi_document_path: str, + openapi_document_path: str | None = None, + openapi_parsed_spec: dict[str, Any] | None = None, execution_settings: "OpenAPIFunctionExecutionParameters | None" = None, description: str | None = None, ) -> KernelPlugin: """Add a plugin from the OpenAPI manifest. Args: - plugin_name (str): The name of the plugin - openapi_document_path (str): The path to the OpenAPI document - execution_settings (OpenAPIFunctionExecutionParameters | None): The execution parameters - description (str | None): The description of the plugin + plugin_name: The name of the plugin + openapi_document_path: The path to the OpenAPI document + openapi_parsed_spec: The parsed OpenAPI spec + execution_settings: The execution parameters + description: The description of the plugin Returns: KernelPlugin: The imported plugin @@ -228,11 +231,16 @@ def add_plugin_from_openapi( KernelPlugin.from_openapi( plugin_name=plugin_name, openapi_document_path=openapi_document_path, + openapi_parsed_spec=openapi_parsed_spec, execution_settings=execution_settings, description=description, ) ) + @deprecated( + "The `add_plugin_from_openai` method is deprecated; use the `add_plugin_from_openapi` method instead.", + category=None, + ) async def add_plugin_from_openai( self, plugin_name: str, diff --git a/python/semantic_kernel/functions/kernel_plugin.py b/python/semantic_kernel/functions/kernel_plugin.py index 296d8beb562e..f4bbb7b71342 100644 --- a/python/semantic_kernel/functions/kernel_plugin.py +++ b/python/semantic_kernel/functions/kernel_plugin.py @@ -13,6 +13,7 @@ import httpx from pydantic import Field, StringConstraints +from typing_extensions import deprecated from semantic_kernel.connectors.openai_plugin.openai_authentication_config import OpenAIAuthenticationConfig from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import ( @@ -357,17 +358,19 @@ def from_directory( def from_openapi( cls: type[_T], plugin_name: str, - openapi_document_path: str, + openapi_document_path: str | None = None, + openapi_parsed_spec: dict[str, Any] | None = None, execution_settings: "OpenAPIFunctionExecutionParameters | None" = None, description: str | None = None, ) -> _T: """Create a plugin from an OpenAPI document. Args: - plugin_name (str): The name of the plugin - openapi_document_path (str): The path to the OpenAPI document - execution_settings (OpenAPIFunctionExecutionParameters | None): The execution parameters - description (str | None): The description of the plugin + plugin_name: The name of the plugin + openapi_document_path: The path to the OpenAPI document (optional) + openapi_parsed_spec: The parsed OpenAPI spec (optional) + execution_settings: The execution parameters + description: The description of the plugin Returns: KernelPlugin: The created plugin @@ -375,8 +378,8 @@ def from_openapi( Raises: PluginInitializationError: if the plugin URL or plugin JSON/YAML is not provided """ - if not openapi_document_path: - raise PluginInitializationError("OpenAPI document path is required.") + if not openapi_document_path and not openapi_parsed_spec: + raise PluginInitializationError("Either the OpenAPI document path or a parsed OpenAPI spec is required.") return cls( # type: ignore name=plugin_name, @@ -384,10 +387,15 @@ def from_openapi( functions=create_functions_from_openapi( # type: ignore plugin_name=plugin_name, openapi_document_path=openapi_document_path, + openapi_parsed_spec=openapi_parsed_spec, execution_settings=execution_settings, ), ) + @deprecated( + "The `OpenAI` plugin is deprecated; use the `from_openapi` method to add an `OpenAPI` plugin instead.", + category=None, + ) @classmethod async def from_openai( cls: type[_T], diff --git a/python/tests/integration/cross_language/test_cross_language.py b/python/tests/integration/cross_language/test_cross_language.py index 456874a95a76..a4e9f3827bec 100644 --- a/python/tests/integration/cross_language/test_cross_language.py +++ b/python/tests/integration/cross_language/test_cross_language.py @@ -12,7 +12,6 @@ from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion from semantic_kernel.connectors.ai.open_ai.settings.open_ai_settings import OpenAISettings -from semantic_kernel.connectors.openapi_plugin import OpenAPIFunctionExecutionParameters from semantic_kernel.functions.kernel_arguments import KernelArguments from semantic_kernel.functions.kernel_function import KernelFunction from semantic_kernel.functions.kernel_function_decorator import kernel_function @@ -539,6 +538,8 @@ async def test_yaml_prompt(is_streaming, prompt_path, expected_result_path, kern async def setup_openapi_function_call(kernel, function_name, arguments): + from semantic_kernel.connectors.openapi_plugin import OpenAPIFunctionExecutionParameters + openapi_spec_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "data", "light_bulb_api.json") request_details = None diff --git a/python/tests/unit/connectors/openapi_plugin/apikey-securityV3_0.json b/python/tests/unit/connectors/openapi_plugin/apikey-securityV3_0.json new file mode 100644 index 000000000000..089c3493dc99 --- /dev/null +++ b/python/tests/unit/connectors/openapi_plugin/apikey-securityV3_0.json @@ -0,0 +1,79 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Semantic Kernel Open API Sample", + "description": "A sample Open API schema with endpoints which have security requirements defined.", + "version": "1.0" + }, + "servers": [ + { + "url": "https://example.org" + } + ], + "paths": { + "/use_global_security": { + "get": { + "summary": "No security defined on operation", + "description": "", + "operationId": "NoSecurity", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + } + }, + "post": { + "summary": "Security defined on operation", + "description": "", + "operationId": "Security", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "ApiKeyAuthQuery": [] + } + ] + }, + "put": { + "summary": "Security defined on operation with new scope", + "description": "", + "operationId": "SecurityAndScope", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "ApiKeyAuthQuery": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuthHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-KEY" + }, + "ApiKeyAuthQuery": { + "type": "apiKey", + "in": "query", + "name": "apiKey" + } + } + }, + "security": [ + { + "ApiKeyAuthHeader": [] + } + ] +} \ No newline at end of file diff --git a/python/tests/unit/connectors/openapi_plugin/no-securityV3_0.json b/python/tests/unit/connectors/openapi_plugin/no-securityV3_0.json new file mode 100644 index 000000000000..f5d5ea2566ad --- /dev/null +++ b/python/tests/unit/connectors/openapi_plugin/no-securityV3_0.json @@ -0,0 +1,74 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Semantic Kernel Open API Sample", + "description": "A sample Open API schema with endpoints which have security requirements defined.", + "version": "1.0" + }, + "servers": [ + { + "url": "https://example.org" + } + ], + "paths": { + "/use_global_security": { + "get": { + "summary": "No security defined on operation", + "description": "", + "operationId": "NoSecurity", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + } + }, + "post": { + "summary": "Security defined on operation", + "description": "", + "operationId": "Security", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "ApiKeyAuthQuery": [] + } + ] + }, + "put": { + "summary": "Security defined on operation with new scope", + "description": "", + "operationId": "SecurityAndScope", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "ApiKeyAuthQuery": ["new_scope"] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "ApiKeyAuthHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-KEY" + }, + "ApiKeyAuthQuery": { + "type": "apiKey", + "in": "query", + "name": "apiKey" + } + } + } +} \ No newline at end of file diff --git a/python/tests/unit/connectors/openapi_plugin/oauth-securityV3_0.json b/python/tests/unit/connectors/openapi_plugin/oauth-securityV3_0.json new file mode 100644 index 000000000000..cd5655c8025c --- /dev/null +++ b/python/tests/unit/connectors/openapi_plugin/oauth-securityV3_0.json @@ -0,0 +1,89 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "Semantic Kernel Open API Sample", + "description": "A sample Open API schema with endpoints which have security requirements defined.", + "version": "1.0" + }, + "servers": [ + { + "url": "https://example.org" + } + ], + "paths": { + "/use_global_security": { + "get": { + "summary": "No security defined on operation", + "description": "", + "operationId": "NoSecurity", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + } + }, + "post": { + "summary": "Security defined on operation", + "description": "", + "operationId": "Security", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "OAuth2Auth": [] + } + ] + }, + "put": { + "summary": "Security defined on operation with new scope", + "description": "", + "operationId": "SecurityAndScope", + "parameters": [], + "responses": { + "200": { + "description": "default" + } + }, + "security": [ + { + "OAuth2Auth": [ "new_scope" ] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "OAuth2Auth": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://login.windows.net/common/oauth2/authorize", + "tokenUrl": "https://login.windows.net/common/oauth2/authorize", + "scopes": {} + } + } + }, + "ApiKeyAuthHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-KEY" + }, + "ApiKeyAuthQuery": { + "type": "apiKey", + "in": "query", + "name": "apiKey" + } + } + }, + "security": [ + { + "OAuth2Auth": [] + } + ] +} \ No newline at end of file diff --git a/python/tests/unit/connectors/openapi_plugin/openapi.yaml b/python/tests/unit/connectors/openapi_plugin/openapi.yaml index c2487b4d29d6..30746c8e3dec 100644 --- a/python/tests/unit/connectors/openapi_plugin/openapi.yaml +++ b/python/tests/unit/connectors/openapi_plugin/openapi.yaml @@ -4,6 +4,7 @@ info: version: 1.0.0 servers: - url: http://example.com + - url: https://example-two.com paths: /todos: get: diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py index de5d834c1361..33823aff4006 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_manager.py @@ -4,9 +4,9 @@ import pytest -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import ( - RestApiOperationParameter, - RestApiOperationParameterLocation, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import ( + RestApiParameter, + RestApiParameterLocation, ) from semantic_kernel.connectors.openapi_plugin.openapi_manager import ( _create_function_from_operation, @@ -26,9 +26,7 @@ async def test_run_openapi_operation_success(kernel: Kernel): operation.summary = "Test Summary" operation.description = "Test Description" operation.get_parameters.return_value = [ - RestApiOperationParameter( - name="param1", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] execution_parameters = MagicMock() @@ -77,9 +75,7 @@ async def test_run_openapi_operation_missing_required_param(kernel: Kernel): operation.summary = "Test Summary" operation.description = "Test Description" operation.get_parameters.return_value = [ - RestApiOperationParameter( - name="param1", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] execution_parameters = MagicMock() @@ -127,9 +123,7 @@ async def test_run_openapi_operation_runner_exception(kernel: Kernel): operation.summary = "Test Summary" operation.description = "Test Description" operation.get_parameters.return_value = [ - RestApiOperationParameter( - name="param1", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] execution_parameters = MagicMock() @@ -177,10 +171,10 @@ async def test_run_openapi_operation_alternative_name(kernel: Kernel): operation.summary = "Test Summary" operation.description = "Test Description" operation.get_parameters.return_value = [ - RestApiOperationParameter( + RestApiParameter( name="param1", type="string", - location=RestApiOperationParameterLocation.QUERY, + location=RestApiParameterLocation.QUERY, is_required=True, alternative_name="alt_param1", ) diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py index 0e4d278a1667..5d99cc5a079f 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py @@ -4,6 +4,7 @@ import pytest +from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation from semantic_kernel.connectors.openapi_plugin.openapi_manager import OpenApiParser, create_functions_from_openapi from semantic_kernel.exceptions.function_exceptions import PluginInitializationError from semantic_kernel.functions import KernelFunctionFromMethod, KernelFunctionMetadata, KernelParameterMetadata @@ -109,3 +110,71 @@ def test_create_rest_api_operation_payload_media_type_none(): request_body = {"content": {"application/xml": {"schema": {"type": "object"}}}} with pytest.raises(Exception, match="Neither of the media types of operation_id is supported."): parser._create_rest_api_operation_payload("operation_id", request_body) + + +def generate_security_member_data(): + return [ + ( + "no-securityV3_0.json", + { + "NoSecurity": [], + "Security": ["apiKey"], + "SecurityAndScope": ["apiKey"], + }, + ), + ( + "apikey-securityV3_0.json", + { + "NoSecurity": [], + "Security": ["apiKey"], + "SecurityAndScope": ["apiKey"], + }, + ), + ( + "oauth-securityV3_0.json", + { + "NoSecurity": [], + "Security": ["oauth2"], + "SecurityAndScope": ["oauth2"], + }, + ), + ] + + +@pytest.mark.parametrize("document_file_name, security_type_map", generate_security_member_data()) +def test_it_adds_security_metadata_to_operation(document_file_name, security_type_map): + # Arrange + current_dir = os.path.dirname(__file__) + openapi_document_path = os.path.join(current_dir, document_file_name) + + # Act + plugin_functions = create_functions_from_openapi( + plugin_name="fakePlugin", + openapi_document_path=openapi_document_path, + execution_settings=None, + ) + + # Assert + for function in plugin_functions: + additional_properties = function.metadata.additional_properties + assert "operation" in additional_properties + + function_name = function.metadata.name + security_types = security_type_map.get(function_name, []) + + operation = additional_properties["operation"] + assert isinstance(operation, RestApiOperation) + assert operation is not None + assert operation.security_requirements is not None + assert len(operation.security_requirements) == len(security_types) + + for security_type in security_types: + found = False + for security_requirement in operation.security_requirements: + for scheme in security_requirement: + if scheme.security_scheme_type.lower() == security_type.lower(): + found = True + break + if found: + break + assert found, f"Security type '{security_type}' not found in operation '{operation.id}'" diff --git a/python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py b/python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py index 43955661d6d2..935ee40df4dc 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_runner.py @@ -6,7 +6,7 @@ import pytest from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation import RestApiOperation -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload +from semantic_kernel.connectors.openapi_plugin.models.rest_api_payload import RestApiPayload from semantic_kernel.connectors.openapi_plugin.openapi_manager import OpenApiRunner from semantic_kernel.exceptions import FunctionExecutionException @@ -31,9 +31,15 @@ def test_build_operation_url(): def test_build_json_payload_dynamic_payload(): runner = OpenApiRunner({}, enable_dynamic_payload=True) - payload_metadata = RestApiOperationPayload( + + mock_property1 = Mock() + mock_property2 = Mock() + mock_property1.freeze = MagicMock() + mock_property2.freeze = MagicMock() + + payload_metadata = RestApiPayload( media_type="application/json", - properties=["property1", "property2"], + properties=[mock_property1, mock_property2], description=None, schema=None, ) @@ -41,6 +47,19 @@ def test_build_json_payload_dynamic_payload(): runner.build_json_object = MagicMock(return_value={"property1": "value1", "property2": "value2"}) + payload_metadata.description = "A dynamic payload" + assert payload_metadata.description == "A dynamic payload" + + payload_metadata.freeze() + + mock_property1.freeze.assert_called_once() + mock_property2.freeze.assert_called_once() + + with pytest.raises( + FunctionExecutionException, match="This `RestApiPayload` instance is frozen and cannot be modified." + ): + payload_metadata.description = "Should raise error" + content, media_type = runner.build_json_payload(payload_metadata, arguments) runner.build_json_object.assert_called_once_with(payload_metadata.properties, arguments) @@ -206,7 +225,7 @@ def test_get_argument_name_for_payload_with_namespacing(): def test_build_operation_payload_with_request_body(): runner = OpenApiRunner({}) - request_body = RestApiOperationPayload( + request_body = RestApiPayload( media_type="application/json", properties=["property1", "property2"], description=None, diff --git a/python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py b/python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py index 29df73cc7040..bca9e0ca97ec 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py +++ b/python/tests/unit/connectors/openapi_plugin/test_rest_api_operation_run_options.py @@ -1,20 +1,20 @@ # Copyright (c) Microsoft. All rights reserved. -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_run_options import RestApiOperationRunOptions +from semantic_kernel.connectors.openapi_plugin.models.rest_api_run_options import RestApiRunOptions def test_initialization(): server_url_override = "http://example.com" api_host_url = "http://example.com" - rest_api_operation_run_options = RestApiOperationRunOptions(server_url_override, api_host_url) + rest_api_operation_run_options = RestApiRunOptions(server_url_override, api_host_url) assert rest_api_operation_run_options.server_url_override == server_url_override assert rest_api_operation_run_options.api_host_url == api_host_url def test_initialization_no_params(): - rest_api_operation_run_options = RestApiOperationRunOptions() + rest_api_operation_run_options = RestApiRunOptions() assert rest_api_operation_run_options.server_url_override is None assert rest_api_operation_run_options.api_host_url is None diff --git a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py index 45229b6f1630..4dbab11ad34a 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py +++ b/python/tests/unit/connectors/openapi_plugin/test_sk_openapi.py @@ -8,19 +8,20 @@ import yaml from openapi_core import Spec -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_expected_response import ( - RestApiOperationExpectedResponse, +from semantic_kernel.connectors.openapi_plugin import OperationSelectionPredicateContext +from semantic_kernel.connectors.openapi_plugin.models.rest_api_expected_response import ( + RestApiExpectedResponse, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter import ( - RestApiOperationParameter, - RestApiOperationParameterLocation, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import ( + RestApiParameter, + RestApiParameterLocation, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_parameter_style import ( - RestApiOperationParameterStyle, +from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter_style import ( + RestApiParameterStyle, ) -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload import RestApiOperationPayload -from semantic_kernel.connectors.openapi_plugin.models.rest_api_operation_payload_property import ( - RestApiOperationPayloadProperty, +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.openapi_function_execution_parameters import ( OpenAPIFunctionExecutionParameters, @@ -46,7 +47,7 @@ put_operation = RestApiOperation( id="updateTodoById", method="PUT", - server_url="http://example.com", + servers="http://example.com", path="/todos/{id}", summary="Update a todo by ID", params=[ @@ -119,7 +120,7 @@ def test_parse_invalid_format(): def test_url_join_with_trailing_slash(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="test/path") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="test/path") base_url = "https://example.com/" path = "test/path" expected_url = "https://example.com/test/path" @@ -127,7 +128,7 @@ def test_url_join_with_trailing_slash(): def test_url_join_without_trailing_slash(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com", path="test/path") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com"], path="test/path") base_url = "https://example.com" path = "test/path" expected_url = "https://example.com/test/path" @@ -135,7 +136,7 @@ def test_url_join_without_trailing_slash(): def test_url_join_base_path_with_path(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/base/", path="test/path") + operation = RestApiOperation(id="test", method="GET", servers="https://example.com/base/", path="test/path") base_url = "https://example.com/base/" path = "test/path" expected_url = "https://example.com/base/test/path" @@ -143,7 +144,7 @@ def test_url_join_base_path_with_path(): def test_url_join_with_leading_slash_in_path(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/test/path") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/test/path") base_url = "https://example.com/" path = "/test/path" expected_url = "https://example.com/test/path" @@ -151,7 +152,7 @@ def test_url_join_with_leading_slash_in_path(): def test_url_join_base_path_without_trailing_slash(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/base", path="test/path") + operation = RestApiOperation(id="test", method="GET", servers="https://example.com/base", path="test/path") base_url = "https://example.com/base" path = "test/path" expected_url = "https://example.com/base/test/path" @@ -160,26 +161,56 @@ def test_url_join_base_path_without_trailing_slash(): def test_build_headers_with_required_parameter(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=True + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=True ) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {"Authorization": "Bearer token"} expected_headers = {"Authorization": "Bearer token"} assert operation.build_headers(arguments) == expected_headers +def test_rest_api_operation_freeze(): + operation = RestApiOperation( + id="test", + method="GET", + servers=["https://example.com/"], + path="test/path", + summary="A test summary", + description="A test description", + params=[], + request_body=None, + responses={}, + security_requirements=[], + ) + + operation.description = "Modified description" + assert operation.description == "Modified description" + + operation.freeze() + + with pytest.raises(FunctionExecutionException, match="is frozen and cannot be modified"): + operation.description = "Another modification" + + with pytest.raises(FunctionExecutionException, match="is frozen and cannot be modified"): + operation.path = "new/test/path" + + if operation.request_body: + with pytest.raises(FunctionExecutionException): + operation.request_body.description = "New request body description" + + def test_build_headers_missing_required_parameter(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=True + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=True ) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {} with pytest.raises( @@ -191,12 +222,12 @@ def test_build_headers_missing_required_parameter(): def test_build_headers_with_optional_parameter(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=False + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=False ) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {"Authorization": "Bearer token"} expected_headers = {"Authorization": "Bearer token"} @@ -205,12 +236,12 @@ def test_build_headers_with_optional_parameter(): def test_build_headers_missing_optional_parameter(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=False + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=False ) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {} expected_headers = {} @@ -219,15 +250,15 @@ def test_build_headers_missing_optional_parameter(): def test_build_headers_multiple_parameters(): parameters = [ - RestApiOperationParameter( - name="Authorization", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=True + RestApiParameter( + name="Authorization", type="string", location=RestApiParameterLocation.HEADER, is_required=True ), - RestApiOperationParameter( - name="Content-Type", type="string", location=RestApiOperationParameterLocation.HEADER, is_required=False + RestApiParameter( + name="Content-Type", type="string", location=RestApiParameterLocation.HEADER, is_required=False ), ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com", path="test/path", params=parameters + id="test", method="GET", servers=["https://example.com"], path="test/path", params=parameters ) arguments = {"Authorization": "Bearer token", "Content-Type": "application/json"} expected_headers = {"Authorization": "Bearer token", "Content-Type": "application/json"} @@ -235,13 +266,9 @@ def test_build_headers_multiple_parameters(): def test_build_operation_url_with_override(): - parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ) - ] + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters ) arguments = {"id": "123"} server_url_override = urlparse("https://override.com") @@ -250,40 +277,109 @@ def test_build_operation_url_with_override(): def test_build_operation_url_without_override(): - parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ) - ] + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}", params=parameters + id="test", + method="GET", + servers=[{"url": "https://example.com/"}], + path="/resource/{id}", + params=parameters, ) arguments = {"id": "123"} expected_url = "https://example.com/resource/123" assert operation.build_operation_url(arguments) == expected_url -def test_get_server_url_with_override(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com", path="/resource/{id}") +def test_get_server_url_with_parse_result_override(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) server_url_override = urlparse("https://override.com") expected_url = "https://override.com/" - assert operation.get_server_url(server_url_override=server_url_override).geturl() == expected_url + assert operation.get_server_url(server_url_override=server_url_override) == expected_url + + +def test_get_server_url_with_string_override(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) + server_url_override = "https://override.com" + expected_url = "https://override.com/" + assert operation.get_server_url(server_url_override=server_url_override) == expected_url + + +def test_get_server_url_with_servers_no_variables(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) + expected_url = "https://example.com/" + assert operation.get_server_url() == expected_url + + +def test_get_server_url_with_servers_and_variables(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[ + { + "url": "https://example.com/{version}", + "variables": {"version": {"default": "v1", "argument_name": "api_version"}}, + } + ], + path="/resource/{id}", + ) + arguments = {"api_version": "v2"} + expected_url = "https://example.com/v2/" + assert operation.get_server_url(arguments=arguments) == expected_url + + +def test_get_server_url_with_servers_and_default_variable(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com/{version}", "variables": {"version": {"default": "v1"}}}], + path="/resource/{id}", + ) + expected_url = "https://example.com/v1/" + assert operation.get_server_url() == expected_url + + +def test_get_server_url_with_override(): + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) + server_url_override = "https://override.com" + expected_url = "https://override.com/" + assert operation.get_server_url(server_url_override=server_url_override) == expected_url def test_get_server_url_without_override(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com", path="/resource/{id}") + operation = RestApiOperation( + id="test", + method="GET", + servers=[{"url": "https://example.com"}], + path="/resource/{id}", + ) expected_url = "https://example.com/" - assert operation.get_server_url().geturl() == expected_url + assert operation.get_server_url() == expected_url def test_build_path_with_required_parameter(): - parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ) - ] + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters ) arguments = {"id": "123"} expected_path = "/resource/123" @@ -291,13 +387,9 @@ def test_build_path_with_required_parameter(): def test_build_path_missing_required_parameter(): - parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ) - ] + parameters = [RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True)] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}", params=parameters ) arguments = {} with pytest.raises( @@ -309,15 +401,11 @@ def test_build_path_missing_required_parameter(): def test_build_path_with_optional_and_required_parameters(): parameters = [ - RestApiOperationParameter( - name="id", type="string", location=RestApiOperationParameterLocation.PATH, is_required=True - ), - RestApiOperationParameter( - name="optional", type="string", location=RestApiOperationParameterLocation.PATH, is_required=False - ), + RestApiParameter(name="id", type="string", location=RestApiParameterLocation.PATH, is_required=True), + RestApiParameter(name="optional", type="string", location=RestApiParameterLocation.PATH, is_required=False), ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource/{id}/{optional}", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource/{id}/{optional}", params=parameters ) arguments = {"id": "123"} expected_path = "/resource/123/{optional}" @@ -326,12 +414,10 @@ def test_build_path_with_optional_and_required_parameters(): def test_build_query_string_with_required_parameter(): parameters = [ - RestApiOperationParameter( - name="query", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource", params=parameters ) arguments = {"query": "value"} expected_query_string = "query=value" @@ -340,12 +426,10 @@ def test_build_query_string_with_required_parameter(): def test_build_query_string_missing_required_parameter(): parameters = [ - RestApiOperationParameter( - name="query", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True - ) + RestApiParameter(name="query", type="string", location=RestApiParameterLocation.QUERY, is_required=True) ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource", params=parameters ) arguments = {} with pytest.raises( @@ -357,15 +441,15 @@ def test_build_query_string_missing_required_parameter(): def test_build_query_string_with_optional_and_required_parameters(): parameters = [ - RestApiOperationParameter( - name="required_param", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=True + RestApiParameter( + name="required_param", type="string", location=RestApiParameterLocation.QUERY, is_required=True ), - RestApiOperationParameter( - name="optional_param", type="string", location=RestApiOperationParameterLocation.QUERY, is_required=False + RestApiParameter( + name="optional_param", type="string", location=RestApiParameterLocation.QUERY, is_required=False ), ] operation = RestApiOperation( - id="test", method="GET", server_url="https://example.com/", path="/resource", params=parameters + id="test", method="GET", servers=["https://example.com/"], path="/resource", params=parameters ) arguments = {"required_param": "required_value"} expected_query_string = "required_param=required_value" @@ -374,7 +458,7 @@ def test_build_query_string_with_optional_and_required_parameters(): def test_create_payload_artificial_parameter_with_text_plain(): properties = [ - RestApiOperationPayloadProperty( + RestApiPayloadProperty( name="prop1", type="string", properties=[], @@ -384,21 +468,21 @@ def test_create_payload_artificial_parameter_with_text_plain(): schema=None, ) ] - request_body = RestApiOperationPayload( + request_body = RestApiPayload( media_type=RestApiOperation.MEDIA_TYPE_TEXT_PLAIN, properties=properties, description="Test description", schema="Test schema", ) operation = RestApiOperation( - id="test", method="POST", server_url="https://example.com/", path="/resource", request_body=request_body + id="test", method="POST", servers=["https://example.com/"], path="/resource", request_body=request_body ) - expected_parameter = RestApiOperationParameter( + expected_parameter = RestApiParameter( name=operation.PAYLOAD_ARGUMENT_NAME, type="string", is_required=True, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="Test description", schema="Test schema", ) @@ -414,7 +498,7 @@ def test_create_payload_artificial_parameter_with_text_plain(): def test_create_payload_artificial_parameter_with_object(): properties = [ - RestApiOperationPayloadProperty( + RestApiPayloadProperty( name="prop1", type="string", properties=[], @@ -424,18 +508,18 @@ def test_create_payload_artificial_parameter_with_object(): schema=None, ) ] - request_body = RestApiOperationPayload( + request_body = RestApiPayload( media_type="application/json", properties=properties, description="Test description", schema="Test schema" ) operation = RestApiOperation( - id="test", method="POST", server_url="https://example.com/", path="/resource", request_body=request_body + id="test", method="POST", servers=["https://example.com/"], path="/resource", request_body=request_body ) - expected_parameter = RestApiOperationParameter( + expected_parameter = RestApiParameter( name=operation.PAYLOAD_ARGUMENT_NAME, type="object", is_required=True, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="Test description", schema="Test schema", ) @@ -450,13 +534,13 @@ def test_create_payload_artificial_parameter_with_object(): def test_create_payload_artificial_parameter_without_request_body(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") - expected_parameter = RestApiOperationParameter( + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") + expected_parameter = RestApiParameter( name=operation.PAYLOAD_ARGUMENT_NAME, type="object", is_required=True, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="REST API request body.", schema=None, ) @@ -471,13 +555,13 @@ def test_create_payload_artificial_parameter_without_request_body(): def test_create_content_type_artificial_parameter(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") - expected_parameter = RestApiOperationParameter( + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") + expected_parameter = RestApiParameter( name=operation.CONTENT_TYPE_ARGUMENT_NAME, type="string", is_required=False, - location=RestApiOperationParameterLocation.BODY, - style=RestApiOperationParameterStyle.SIMPLE, + location=RestApiParameterLocation.BODY, + style=RestApiParameterStyle.SIMPLE, description="Content type of REST API request body.", ) parameter = operation.create_content_type_artificial_parameter() @@ -490,32 +574,28 @@ def test_create_content_type_artificial_parameter(): def test_get_property_name_with_namespacing_and_root_property(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") - property = RestApiOperationPayloadProperty( - name="child", type="string", properties=[], description="Property description" - ) + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") + property = RestApiPayloadProperty(name="child", type="string", properties=[], description="Property description") result = operation._get_property_name(property, root_property_name="root", enable_namespacing=True) assert result == "root.child" def test_get_property_name_without_namespacing(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") - property = RestApiOperationPayloadProperty( - name="child", type="string", properties=[], description="Property description" - ) + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") + property = RestApiPayloadProperty(name="child", type="string", properties=[], description="Property description") result = operation._get_property_name(property, root_property_name="root", enable_namespacing=False) assert result == "child" def test_get_payload_parameters_with_metadata_and_text_plain(): properties = [ - RestApiOperationPayloadProperty(name="prop1", type="string", properties=[], description="Property description") + RestApiPayloadProperty(name="prop1", type="string", properties=[], description="Property description") ] - request_body = RestApiOperationPayload( + request_body = RestApiPayload( media_type=RestApiOperation.MEDIA_TYPE_TEXT_PLAIN, properties=properties, description="Test description" ) operation = RestApiOperation( - id="test", method="POST", server_url="https://example.com/", path="/resource", request_body=request_body + id="test", method="POST", servers=["https://example.com/"], path="/resource", request_body=request_body ) result = operation.get_payload_parameters(operation, use_parameters_from_metadata=True, enable_namespacing=True) assert len(result) == 1 @@ -524,13 +604,11 @@ def test_get_payload_parameters_with_metadata_and_text_plain(): def test_get_payload_parameters_with_metadata_and_json(): properties = [ - RestApiOperationPayloadProperty(name="prop1", type="string", properties=[], description="Property description") + RestApiPayloadProperty(name="prop1", type="string", properties=[], description="Property description") ] - request_body = RestApiOperationPayload( - media_type="application/json", properties=properties, description="Test description" - ) + request_body = RestApiPayload(media_type="application/json", properties=properties, description="Test description") operation = RestApiOperation( - id="test", method="POST", server_url="https://example.com/", path="/resource", request_body=request_body + id="test", method="POST", servers=["https://example.com/"], path="/resource", request_body=request_body ) result = operation.get_payload_parameters(operation, use_parameters_from_metadata=True, enable_namespacing=True) assert len(result) == len(properties) @@ -538,7 +616,7 @@ def test_get_payload_parameters_with_metadata_and_json(): def test_get_payload_parameters_without_metadata(): - operation = RestApiOperation(id="test", method="POST", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="POST", servers=["https://example.com/"], path="/resource") result = operation.get_payload_parameters(operation, use_parameters_from_metadata=False, enable_namespacing=False) assert len(result) == 2 assert result[0].name == operation.PAYLOAD_ARGUMENT_NAME @@ -549,7 +627,7 @@ def test_get_payload_parameters_raises_exception(): operation = RestApiOperation( id="test", method="POST", - server_url="https://example.com/", + servers=["https://example.com/"], path="/resource", request_body=None, ) @@ -561,12 +639,10 @@ def test_get_payload_parameters_raises_exception(): def test_get_default_response(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = { - "200": RestApiOperationExpectedResponse( - description="Success", media_type="application/json", schema={"type": "object"} - ), - "default": RestApiOperationExpectedResponse( + "200": RestApiExpectedResponse(description="Success", media_type="application/json", schema={"type": "object"}), + "default": RestApiExpectedResponse( description="Default response", media_type="application/json", schema={"type": "object"} ), } @@ -576,9 +652,9 @@ def test_get_default_response(): def test_get_default_response_with_default(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = { - "default": RestApiOperationExpectedResponse( + "default": RestApiExpectedResponse( description="Default response", media_type="application/json", schema={"type": "object"} ) } @@ -588,7 +664,7 @@ def test_get_default_response_with_default(): def test_get_default_response_none(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = {} preferred_responses = ["200", "default"] result = operation.get_default_response(responses, preferred_responses) @@ -596,12 +672,10 @@ def test_get_default_response_none(): def test_get_default_return_parameter_with_response(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = { - "200": RestApiOperationExpectedResponse( - description="Success", media_type="application/json", schema={"type": "object"} - ), - "default": RestApiOperationExpectedResponse( + "200": RestApiExpectedResponse(description="Success", media_type="application/json", schema={"type": "object"}), + "default": RestApiExpectedResponse( description="Default response", media_type="application/json", schema={"type": "object"} ), } @@ -614,7 +688,7 @@ def test_get_default_return_parameter_with_response(): def test_get_default_return_parameter_none(): - operation = RestApiOperation(id="test", method="GET", server_url="https://example.com/", path="/resource") + operation = RestApiOperation(id="test", method="GET", servers=["https://example.com/"], path="/resource") responses = {} operation.responses = responses result = operation.get_default_return_parameter(preferred_responses=["200", "default"]) @@ -657,6 +731,61 @@ async def dummy_auth_callback(**kwargs): return runner, operations +@pytest.fixture +def openapi_runner_with_predicate_callback(): + # Define a dummy predicate callback + def predicate_callback(context): + # Skip operations with DELETE method or containing 'internal' in the path + return context.method != "DELETE" and "internal" not in context.path + + parser = OpenApiParser() + parsed_doc = parser.parse(openapi_document) + exec_settings = OpenAPIFunctionExecutionParameters( + server_url_override="http://urloverride.com", + operation_selection_predicate=predicate_callback, + ) + operations = parser.create_rest_api_operations(parsed_doc, execution_settings=exec_settings) + runner = OpenApiRunner(parsed_openapi_document=parsed_doc) + return runner, operations, exec_settings + + +def test_predicate_callback_applied(openapi_runner_with_predicate_callback): + _, operations, exec_settings = openapi_runner_with_predicate_callback + + skipped_operations = [] + executed_operations = [] + + # Iterate over the operation objects instead of just the keys + for operation_id, operation in operations.items(): + context = OperationSelectionPredicateContext( + operation_id=operation_id, + path=operation.path, + method=operation.method, + description=operation.description, + ) + if not exec_settings.operation_selection_predicate(context): + skipped_operations.append(operation_id) + else: + executed_operations.append(operation_id) + + # Assertions to verify the callback's behavior + assert len(skipped_operations) > 0, "No operations were skipped, predicate not applied correctly" + assert len(executed_operations) > 0, "All operations were skipped, predicate not applied correctly" + + # Example specific checks based on the callback logic + for operation_id in skipped_operations: + operation = operations[operation_id] + assert operation.method == "DELETE" or "internal" in operation.path, ( + f"Predicate incorrectly skipped operation {operation_id}" + ) + + for operation_id in executed_operations: + operation = operations[operation_id] + assert operation.method != "DELETE" and "internal" not in operation.path, ( + f"Predicate incorrectly executed operation {operation_id}" + ) + + @patch("aiohttp.ClientSession.request") @pytest.mark.asyncio async def test_run_operation_with_invalid_request(mock_request, openapi_runner): diff --git a/python/tests/unit/functions/test_kernel_plugins.py b/python/tests/unit/functions/test_kernel_plugins.py index a30f16f6b2c4..986bbee99aea 100644 --- a/python/tests/unit/functions/test_kernel_plugins.py +++ b/python/tests/unit/functions/test_kernel_plugins.py @@ -13,6 +13,7 @@ from semantic_kernel.connectors.openai_plugin.openai_function_execution_parameters import ( OpenAIFunctionExecutionParameters, ) +from semantic_kernel.connectors.openapi_plugin.openapi_parser import OpenApiParser from semantic_kernel.exceptions.function_exceptions import PluginInitializationError from semantic_kernel.functions import kernel_function from semantic_kernel.functions.kernel_function import KernelFunction @@ -589,7 +590,25 @@ def test_from_openapi(): assert plugin.functions.get("SetSecret") is not None -def test_from_openapi_missing_document_throws(): +def test_custom_spec_from_openapi(): + openapi_spec_file = os.path.join( + os.path.dirname(__file__), "../../assets/test_plugins", "TestOpenAPIPlugin", "akv-openapi.yaml" + ) + + parser = OpenApiParser() + openapi_spec = parser.parse(openapi_spec_file) + + plugin = KernelPlugin.from_openapi( + plugin_name="TestOpenAPIPlugin", + openapi_parsed_spec=openapi_spec, + ) + assert plugin is not None + assert plugin.name == "TestOpenAPIPlugin" + assert plugin.functions.get("GetSecret") is not None + assert plugin.functions.get("SetSecret") is not None + + +def test_from_openapi_missing_document_and_parsed_spec_throws(): with raises(PluginInitializationError): KernelPlugin.from_openapi( plugin_name="TestOpenAPIPlugin",