diff --git a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py index 34bea1bff2dd..8831b7d62ae1 100644 --- a/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py +++ b/python/semantic_kernel/connectors/openapi_plugin/openapi_parser.py @@ -25,6 +25,10 @@ logger: logging.Logger = logging.getLogger(__name__) +# Keys of an OpenAPI path item that describe an operation; the others (summary, description, servers, +# parameters, $ref, x-*) apply to the path as a whole. +HTTP_METHODS: Final = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"}) + class OpenApiParser: """NOTE: SK Python only supports the OpenAPI Spec >=3.0. @@ -250,8 +254,11 @@ def create_rest_api_operations( server_urls = [{"url": "/", "variables": {}, "description": ""}] for path, methods in paths.items(): + path_parameters = methods.get("parameters", []) for method, details in methods.items(): request_method = method.lower() + if request_method not in HTTP_METHODS: + continue # Validate that operationId exists if "operationId" not in details: raise PluginInitializationError(f"operationId missing, path: '{path}', method: '{method}'") @@ -290,7 +297,12 @@ def create_rest_api_operations( logger.info(f"Skipping operation {operationId} as it is excluded.") continue - parameters = details.get("parameters", []) + # Operation level parameters override path level ones with the same name and location. + operation_parameters = details.get("parameters", []) + overridden = {(p.get("name"), p.get("in")) for p in operation_parameters} + parameters = [ + p for p in path_parameters if (p.get("name"), p.get("in")) not in overridden + ] + operation_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", {}))) 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 b0c81074e5a8..9b9394f29d10 100644 --- a/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py +++ b/python/tests/unit/connectors/openapi_plugin/test_openapi_parser.py @@ -383,3 +383,45 @@ def test_create_functions_propagates_enable_file_ref_resolution(): enable_file_ref_resolution=True, enable_http_ref_resolution=False, ) + + +def _path_item_spec(path_item: dict) -> dict: + return { + "openapi": "3.0.1", + "info": {"title": "t", "version": "1"}, + "servers": [{"url": "https://api.example.com"}], + "paths": {"/items/{id}": path_item}, + } + + +def test_create_rest_api_operations_path_item_keys_and_shared_parameters(): + id_param = {"name": "id", "in": "path", "required": True, "schema": {"type": "string"}} + spec = _path_item_spec({ + "summary": "Item", + "description": "An item", + "parameters": [id_param], + "get": {"operationId": "getItem", "responses": {"200": {"description": "ok"}}}, + "delete": {"operationId": "deleteItem", "responses": {"200": {"description": "ok"}}}, + }) + + operations = OpenApiParser().create_rest_api_operations(spec) + + assert set(operations) == {"getItem", "deleteItem"} + for operation in operations.values(): + assert [p.name for p in operation.parameters] == ["id"] + + +def test_create_rest_api_operations_operation_parameter_overrides_path_parameter(): + spec = _path_item_spec({ + "parameters": [{"name": "id", "in": "path", "required": True, "schema": {"type": "string"}}], + "get": { + "operationId": "getItem", + "parameters": [ + {"name": "id", "in": "path", "required": True, "description": "own", "schema": {"type": "integer"}} + ], + "responses": {"200": {"description": "ok"}}, + }, + }) + + operation = OpenApiParser().create_rest_api_operations(spec)["getItem"] + assert [(p.name, p.type, p.description) for p in operation.parameters] == [("id", "integer", "own")]