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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:21:23 +08:00
commit b957a53def
5423 changed files with 863745 additions and 0 deletions
@@ -0,0 +1,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": []
}
]
}
@@ -0,0 +1,32 @@
openapi: 3.0.3
info:
title: Simple HTTPBin API
version: 1.0.0
servers:
- url: https://httpbin.org
paths:
/get:
get:
operationId: duplicateId
summary: Simple GET request to httpbin.org
responses:
'200':
description: Successful response from httpbin
content:
application/json:
schema:
type: object
/ip:
get:
operationId: duplicateId
summary: Get client IP address from httpbin
responses:
'200':
description: Successful response with IP
content:
application/json:
schema:
type: object
@@ -0,0 +1,21 @@
openapi: 3.0.0
info:
title: Test API
version: 1.0.0
servers:
- url: http://example.com
paths:
/todos:
get:
summary: Get all todos
operationId: getTodos
responses:
'200':
description: OK
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: The authorization token
@@ -0,0 +1,17 @@
openapi: 3.0.3
info:
title: Simple HTTPBin API
version: 1.0.0
servers:
- url: https://httpbin.org
paths:
/get:
get:
summary: Simple GET request to httpbin.org
responses:
'200':
description: Successful response from httpbin
content:
application/json:
schema:
type: object
@@ -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"
}
}
}
}
@@ -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": []
}
]
}
@@ -0,0 +1,129 @@
openapi: 3.0.0
info:
title: Test API
version: 1.0.0
servers:
- url: http://example.com
- url: https://example-two.com
paths:
/todos:
get:
summary: Get all todos
operationId: getTodos
responses:
'200':
description: OK
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: The authorization token
post:
summary: Add a new todo
operationId: addTodo
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
description: The title of the todo
example: Buy milk
completed:
type: boolean
description: Whether the todo is completed or not
example: false
responses:
'201':
description: Created
parameters:
- name: Authorization
in: header
required: true
schema:
type: string
description: The authorization token
/todos/{id}:
get:
summary: Get a todo by ID
operationId: getTodoById
parameters:
- name: id
in: path
required: true
schema:
type: integer
minimum: 1
- name: Authorization
in: header
required: true
schema:
type: string
description: The authorization token
responses:
'200':
description: OK
put:
summary: Update a todo by ID
operationId: updateTodoById
parameters:
- name: id
in: path
required: true
schema:
type: integer
minimum: 1
- name: Authorization
in: header
required: true
schema:
type: string
description: The authorization token
- name: completed
in: query
required: false
schema:
type: boolean
description: Whether the todo is completed or not
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
description: The title of the todo
example: Buy milk
completed:
type: boolean
description: Whether the todo is completed or not
example: false
responses:
'200':
description: OK
delete:
summary: Delete a todo by ID
operationId: deleteTodoById
parameters:
- name: id
in: path
required: true
schema:
type: integer
minimum: 1
- name: Authorization
in: header
required: true
schema:
type: string
description: The authorization token
responses:
'204':
description: No Content
@@ -0,0 +1,57 @@
openapi: 3.0.0
info:
title: Todo List API
version: 1.0.0
description: API for managing todo lists
paths:
/list:
get:
summary: Get todo list
operationId: get_todo_list
description: get todo list from specific group
parameters:
- name: listName
in: query
required: true
description: todo list group name description
schema:
type: string
description: todo list group name
responses:
"200":
description: Successful response
content:
application/json:
schema:
type: array
items:
type: object
properties:
task:
type: string
listName:
type: string
/add:
post:
summary: Add a task to a list
operationId: add_todo_list
description: add todo to specific group
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- task
properties:
task:
type: string
description: task name
listName:
type: string
description: task group name
responses:
"201":
description: Task added successfully
@@ -0,0 +1,311 @@
# Copyright (c) Microsoft. All rights reserved.
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from semantic_kernel.connectors.openapi_plugin.models.rest_api_parameter import (
RestApiParameter,
RestApiParameterLocation,
)
from semantic_kernel.connectors.openapi_plugin.models.rest_api_run_options import RestApiRunOptions
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
OpenAPIFunctionExecutionParameters,
)
from semantic_kernel.connectors.openapi_plugin.openapi_manager import (
_create_function_from_operation,
create_functions_from_openapi,
)
from semantic_kernel.connectors.openapi_plugin.openapi_runner import OpenApiRunner
from semantic_kernel.connectors.openapi_plugin.server_url_validator import ServerUrlValidationOptions
from semantic_kernel.exceptions import FunctionExecutionException
from semantic_kernel.functions.kernel_function_decorator import kernel_function
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
from semantic_kernel.kernel import Kernel
async def test_run_openapi_operation_success(kernel: Kernel):
runner = AsyncMock()
operation = MagicMock()
operation.id = "test_operation"
operation.summary = "Test Summary"
operation.description = "Test Description"
operation.get_parameters.return_value = [
RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True)
]
execution_parameters = MagicMock()
execution_parameters.server_url_override = "https://override.com"
execution_parameters.enable_dynamic_payload = True
execution_parameters.enable_payload_namespacing = False
plugin_name = "TestPlugin"
document_uri = "https://document.com"
run_operation_mock = AsyncMock(return_value="Operation Result")
runner.run_operation = run_operation_mock
with patch.object(
operation,
"get_default_return_parameter",
return_value=KernelParameterMetadata(
name="return",
description="Return description",
default_value=None,
type_="string",
type_object=None,
is_required=False,
schema_data={"type": "string"},
),
):
@kernel_function(description=operation.summary, name=operation.id)
async def run_openapi_operation(kernel, **kwargs):
return await _create_function_from_operation(
runner, operation, plugin_name, execution_parameters, document_uri
)(kernel, **kwargs)
kwargs = {"param1": "value1"}
result = await run_openapi_operation(kernel, **kwargs)
assert str(result) == "Operation Result"
run_operation_mock.assert_called_once()
async def test_run_openapi_operation_missing_required_param(kernel: Kernel):
runner = AsyncMock()
operation = MagicMock()
operation.id = "test_operation"
operation.summary = "Test Summary"
operation.description = "Test Description"
operation.get_parameters.return_value = [
RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True)
]
execution_parameters = MagicMock()
execution_parameters.server_url_override = "https://override.com"
execution_parameters.enable_dynamic_payload = True
execution_parameters.enable_payload_namespacing = False
plugin_name = "TestPlugin"
document_uri = "https://document.com"
with patch.object(
operation,
"get_default_return_parameter",
return_value=KernelParameterMetadata(
name="return",
description="Return description",
default_value=None,
type_="string",
type_object=None,
is_required=False,
schema_data={"type": "string"},
),
):
@kernel_function(description=operation.summary, name=operation.id)
async def run_openapi_operation(kernel, **kwargs):
return await _create_function_from_operation(
runner, operation, plugin_name, execution_parameters, document_uri
)(kernel, **kwargs)
kwargs = {}
with pytest.raises(
FunctionExecutionException,
match="Parameter param1 is required but not provided in the arguments",
):
await run_openapi_operation(kernel, **kwargs)
async def test_run_openapi_operation_runner_exception(kernel: Kernel):
runner = AsyncMock()
operation = MagicMock()
operation.id = "test_operation"
operation.summary = "Test Summary"
operation.description = "Test Description"
operation.get_parameters.return_value = [
RestApiParameter(name="param1", type="string", location=RestApiParameterLocation.QUERY, is_required=True)
]
execution_parameters = MagicMock()
execution_parameters.server_url_override = "https://override.com"
execution_parameters.enable_dynamic_payload = True
execution_parameters.enable_payload_namespacing = False
plugin_name = "TestPlugin"
document_uri = "https://document.com"
run_operation_mock = AsyncMock(side_effect=Exception("Runner Exception"))
runner.run_operation = run_operation_mock
with patch.object(
operation,
"get_default_return_parameter",
return_value=KernelParameterMetadata(
name="return",
description="Return description",
default_value=None,
type_="string",
type_object=None,
is_required=False,
schema_data={"type": "string"},
),
):
@kernel_function(description=operation.summary, name=operation.id)
async def run_openapi_operation(kernel, **kwargs):
return await _create_function_from_operation(
runner, operation, plugin_name, execution_parameters, document_uri
)(kernel, **kwargs)
kwargs = {"param1": "value1"}
with pytest.raises(FunctionExecutionException, match="Error running OpenAPI operation: test_operation"):
await run_openapi_operation(kernel, **kwargs)
async def test_run_openapi_operation_alternative_name(kernel: Kernel):
runner = AsyncMock()
operation = MagicMock()
operation.id = "test_operation"
operation.summary = "Test Summary"
operation.description = "Test Description"
operation.get_parameters.return_value = [
RestApiParameter(
name="param1",
type="string",
location=RestApiParameterLocation.QUERY,
is_required=True,
alternative_name="alt_param1",
)
]
execution_parameters = MagicMock()
execution_parameters.server_url_override = "https://override.com"
execution_parameters.enable_dynamic_payload = True
execution_parameters.enable_payload_namespacing = False
plugin_name = "TestPlugin"
document_uri = "https://document.com"
run_operation_mock = AsyncMock(return_value="Operation Result")
runner.run_operation = run_operation_mock
with patch.object(
operation,
"get_default_return_parameter",
return_value=KernelParameterMetadata(
name="return",
description="Return description",
default_value=None,
type_="string",
type_object=None,
is_required=False,
schema_data={"type": "string"},
),
):
@kernel_function(description=operation.summary, name=operation.id)
async def run_openapi_operation(kernel, **kwargs):
return await _create_function_from_operation(
runner, operation, plugin_name, execution_parameters, document_uri
)(kernel, **kwargs)
kwargs = {"alt_param1": "value1"}
result = await run_openapi_operation(kernel, **kwargs)
assert str(result) == "Operation Result"
run_operation_mock.assert_called_once()
assert runner.run_operation.call_args[0][1]["param1"] == "value1"
@patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.OpenApiParser.parse", return_value=None)
async def test_create_functions_from_openapi_raises_exception(mock_parse):
"""Test that an exception is raised when parsing fails."""
with pytest.raises(FunctionExecutionException, match="Error parsing OpenAPI document: test_openapi_document_path"):
create_functions_from_openapi(plugin_name="test_plugin", openapi_document_path="test_openapi_document_path")
mock_parse.assert_called_once_with(
"test_openapi_document_path",
enable_file_ref_resolution=False,
enable_http_ref_resolution=False,
)
async def test_run_operation_uses_timeout_from_run_options():
minimal_openapi_spec = {
"openapi": "3.0.0",
"info": {"title": "Test", "version": "1.0.0"},
"paths": {},
}
runner = OpenApiRunner(
parsed_openapi_document=minimal_openapi_spec,
server_url_validation_options=ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com"]),
)
operation = MagicMock()
operation.method = "GET"
operation.build_headers.return_value = {}
operation.build_operation_payload.return_value = ("{}", None)
operation.responses = {}
operation.responses = {}
operation.operation_id = "test_id"
desired_timeout = 3.3
with (
patch("httpx.AsyncClient.__aenter__", new_callable=AsyncMock) as mock_enter,
patch("httpx.AsyncClient.__aexit__", new_callable=AsyncMock),
patch("httpx.AsyncClient.__init__", return_value=None) as mock_init,
):
mock_client = MagicMock()
mock_enter.return_value = mock_client
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.text = "FAKE_RESPONSE"
mock_client.request = AsyncMock(return_value=mock_response)
operation.build_query_string.return_value = ""
operation.server_url = "https://api.example.com"
operation.path = "/test"
operation.build_operation_url.return_value = "https://api.example.com/test"
result = await runner.run_operation(
operation=operation, arguments=None, options=RestApiRunOptions(timeout=desired_timeout)
)
assert result == "FAKE_RESPONSE"
found = False
for call_args in mock_init.call_args_list:
if "timeout" in call_args.kwargs and call_args.kwargs["timeout"] == desired_timeout:
found = True
break
assert found, f"httpx.AsyncClient was not called with timeout={desired_timeout}"
@patch("semantic_kernel.connectors.openapi_plugin.openapi_manager.OpenApiRunner")
@patch("semantic_kernel.connectors.openapi_plugin.openapi_manager.OpenApiParser")
def test_create_functions_from_openapi_propagates_server_url_validation_settings(mock_parser_class, mock_runner_class):
parsed_doc = {
"openapi": "3.0.0",
"info": {"title": "Test", "version": "1.0.0"},
"paths": {},
}
mock_parser = MagicMock()
mock_parser.parse.return_value = parsed_doc
mock_parser.create_rest_api_operations.return_value = {}
mock_parser_class.return_value = mock_parser
execution_settings = OpenAPIFunctionExecutionParameters(
server_url_validation_allowed_base_urls=["http://192.168.1.100/v1"],
allow_private_network_access=True,
)
create_functions_from_openapi(
plugin_name="test_plugin",
openapi_document_path="test_openapi_document_path",
execution_settings=execution_settings,
)
validation_options = mock_runner_class.call_args.kwargs["server_url_validation_options"]
assert validation_options.allowed_base_urls == ["http://192.168.1.100/v1"]
assert validation_options.allow_private_network_access is True
@@ -0,0 +1,385 @@
# Copyright (c) Microsoft. All rights reserved.
import os
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
current_dir = os.path.dirname(os.path.abspath(__file__))
def test_parse_parameters_missing_in_field():
parser = OpenApiParser()
parameters = [{"name": "param1", "schema": {"type": "string"}}]
with pytest.raises(PluginInitializationError, match="Parameter param1 is missing 'in' field"):
parser._parse_parameters(parameters)
def test_parse_parameters_get_query():
"""Verify whether the get request query parameter can be successfully parsed"""
openapi_fcs: list[KernelFunctionFromMethod] = create_functions_from_openapi(
plugin_name="todo",
openapi_document_path=os.path.join(current_dir, "openapi_todo.yaml"),
execution_settings=None,
)
get_todo_list: list[KernelFunctionMetadata] = [
f.metadata for f in openapi_fcs if f.metadata.name == "get_todo_list"
]
assert get_todo_list
get_todo_params: list[KernelParameterMetadata] = get_todo_list[0].parameters
assert get_todo_params
assert get_todo_params[0].name == "listName"
assert get_todo_params[0].description == "todo list group name description"
assert get_todo_params[0].is_required
assert get_todo_params[0].schema_data
assert get_todo_params[0].schema_data.get("type") == "string"
assert get_todo_params[0].schema_data.get("description") == "todo list group name"
def test_parse_parameters_post_request_body():
"""Verify whether the post request body parameter can be successfully parsed"""
openapi_fcs: list[KernelFunctionFromMethod] = create_functions_from_openapi(
plugin_name="todo",
openapi_document_path=os.path.join(current_dir, "openapi_todo.yaml"),
execution_settings=None,
)
add_todo_list: list[KernelFunctionMetadata] = [
f.metadata for f in openapi_fcs if f.metadata.name == "add_todo_list"
]
assert add_todo_list
add_todo_params: list[KernelParameterMetadata] = add_todo_list[0].parameters
assert add_todo_params
assert add_todo_params[0].name == "task"
assert add_todo_params[0].description == "task name"
assert add_todo_params[0].is_required
assert add_todo_params[0].schema_data
assert add_todo_params[0].schema_data.get("type") == "string"
assert add_todo_params[0].schema_data.get("description") == "task name"
assert add_todo_params[1].name == "listName"
assert add_todo_params[1].description == "task group name"
assert not add_todo_params[1].is_required
assert add_todo_params[1].schema_data
assert add_todo_params[1].schema_data.get("type") == "string"
assert add_todo_params[1].schema_data.get("description") == "task group name"
def test_get_payload_properties_schema_none():
parser = OpenApiParser()
properties = parser._get_payload_properties("operation_id", None, [])
assert properties == []
def test_get_payload_properties_hierarchy_max_depth_exceeded():
parser = OpenApiParser()
schema = {
"properties": {
"prop1": {
"type": "object",
"properties": {
"prop2": {
"type": "object",
"properties": {
# Nested properties to exceed max depth
},
}
},
}
}
}
with pytest.raises(
Exception,
match=f"Max level {OpenApiParser.PAYLOAD_PROPERTIES_HIERARCHY_MAX_DEPTH} of traversing payload properties of `operation_id` operation is exceeded.", # noqa: E501
):
parser._get_payload_properties("operation_id", schema, [], level=11)
def test_create_rest_api_operation_payload_media_type_none():
parser = OpenApiParser()
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}'"
def test_no_operationid_raises_error():
"""Test that OpenAPI spec without operationId raises PluginInitializationError."""
no_op_path = os.path.join(current_dir, "no-operationid-openapi.yaml")
with pytest.raises(PluginInitializationError, match="operationId missing"):
create_functions_from_openapi(
plugin_name="noOpTest",
openapi_document_path=no_op_path,
execution_settings=None,
)
def test_parse_blocks_external_http_refs_by_default():
"""Verify that external HTTP $ref resolution is not enabled by default."""
from unittest.mock import MagicMock, patch
from prance.util.resolver import RESOLVE_HTTP, RESOLVE_INTERNAL
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
parser = OpenApiParser()
parser.parse("dummy_path.yaml")
mock_parser_cls.assert_called_once()
call_kwargs = mock_parser_cls.call_args
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
assert resolve_types == RESOLVE_INTERNAL, (
f"Expected only RESOLVE_INTERNAL ({RESOLVE_INTERNAL}), got {resolve_types}"
)
assert not (resolve_types & RESOLVE_HTTP), "RESOLVE_HTTP should not be set by default"
def test_parse_resolves_internal_refs_by_default(tmp_path):
"""Verify that internal $ref references are still resolved by default."""
openapi_spec = tmp_path / "spec_with_internal_ref.yaml"
openapi_spec.write_text(
"""
openapi: 3.0.0
info:
title: Internal Ref Test
version: 1.0.0
servers:
- url: http://example.com
paths:
/test:
get:
operationId: testOp
responses:
"200":
description: ok
content:
application/json:
schema:
$ref: "#/components/schemas/TestSchema"
components:
schemas:
TestSchema:
type: object
properties:
name:
type: string
""",
encoding="utf-8",
)
parser = OpenApiParser()
result = parser.parse(str(openapi_spec))
# Internal $ref should be resolved
response_schema = result["paths"]["/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
assert "$ref" not in response_schema, "Internal $ref should be resolved"
assert response_schema["type"] == "object"
assert "name" in response_schema["properties"]
def test_parse_blocks_file_refs_by_default():
"""Verify that local file $ref resolution is not enabled by default."""
from unittest.mock import MagicMock, patch
from prance.util.resolver import RESOLVE_FILES, RESOLVE_INTERNAL
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
parser = OpenApiParser()
parser.parse("dummy_path.yaml")
call_kwargs = mock_parser_cls.call_args
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
assert resolve_types == RESOLVE_INTERNAL, (
f"Expected only RESOLVE_INTERNAL ({RESOLVE_INTERNAL}), got {resolve_types}"
)
assert not (resolve_types & RESOLVE_FILES), "RESOLVE_FILES should not be set by default"
def test_parse_enables_file_refs_when_requested():
"""Verify that local file $ref resolution is enabled when explicitly requested."""
from unittest.mock import MagicMock, patch
from prance.util.resolver import RESOLVE_FILES, RESOLVE_INTERNAL
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
parser = OpenApiParser()
parser.parse("dummy_path.yaml", enable_file_ref_resolution=True)
call_kwargs = mock_parser_cls.call_args
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_FILES), (
f"Expected RESOLVE_INTERNAL | RESOLVE_FILES, got {resolve_types}"
)
def test_parse_enables_http_refs_when_requested():
"""Verify that HTTP $ref resolution is enabled when explicitly requested."""
from unittest.mock import MagicMock, patch
from prance.util.resolver import RESOLVE_HTTP, RESOLVE_INTERNAL
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
parser = OpenApiParser()
parser.parse("dummy_path.yaml", enable_http_ref_resolution=True)
call_kwargs = mock_parser_cls.call_args
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_HTTP), (
f"Expected RESOLVE_INTERNAL | RESOLVE_HTTP, got {resolve_types}"
)
def test_parse_enables_both_file_and_http_refs_when_requested():
"""Verify both file and HTTP $ref resolution work together."""
from unittest.mock import MagicMock, patch
from prance.util.resolver import RESOLVE_FILES, RESOLVE_HTTP, RESOLVE_INTERNAL
with patch("semantic_kernel.connectors.openapi_plugin.openapi_parser.ResolvingParser") as mock_parser_cls:
mock_parser_cls.return_value = MagicMock(specification={"openapi": "3.0.0"})
parser = OpenApiParser()
parser.parse("dummy_path.yaml", enable_file_ref_resolution=True, enable_http_ref_resolution=True)
call_kwargs = mock_parser_cls.call_args
resolve_types = call_kwargs.kwargs.get("resolve_types") or call_kwargs[1].get("resolve_types")
assert resolve_types == (RESOLVE_INTERNAL | RESOLVE_FILES | RESOLVE_HTTP), (
f"Expected RESOLVE_INTERNAL | RESOLVE_FILES | RESOLVE_HTTP, got {resolve_types}"
)
def test_create_functions_propagates_enable_http_ref_resolution():
"""Verify enable_http_ref_resolution=True is propagated from settings to parser."""
from unittest.mock import patch
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
OpenAPIFunctionExecutionParameters,
)
minimal_spec = {
"openapi": "3.0.0",
"info": {"title": "Test", "version": "1.0.0"},
"paths": {},
}
settings = OpenAPIFunctionExecutionParameters(enable_http_ref_resolution=True)
with patch.object(OpenApiParser, "parse", return_value=minimal_spec) as mock_parse:
create_functions_from_openapi(
plugin_name="testPlugin",
openapi_document_path="dummy.yaml",
execution_settings=settings,
)
mock_parse.assert_called_once_with(
"dummy.yaml",
enable_file_ref_resolution=False,
enable_http_ref_resolution=True,
)
def test_create_functions_propagates_enable_file_ref_resolution():
"""Verify enable_file_ref_resolution=True is propagated from settings to parser."""
from unittest.mock import patch
from semantic_kernel.connectors.openapi_plugin.openapi_function_execution_parameters import (
OpenAPIFunctionExecutionParameters,
)
minimal_spec = {
"openapi": "3.0.0",
"info": {"title": "Test", "version": "1.0.0"},
"paths": {},
}
settings = OpenAPIFunctionExecutionParameters(enable_file_ref_resolution=True)
with patch.object(OpenApiParser, "parse", return_value=minimal_spec) as mock_parse:
create_functions_from_openapi(
plugin_name="testPlugin",
openapi_document_path="dummy.yaml",
execution_settings=settings,
)
mock_parse.assert_called_once_with(
"dummy.yaml",
enable_file_ref_resolution=True,
enable_http_ref_resolution=False,
)
@@ -0,0 +1,365 @@
# Copyright (c) Microsoft. All rights reserved.
from collections import OrderedDict
from unittest.mock import AsyncMock, MagicMock, Mock
import pytest
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.openapi_manager import OpenApiRunner
from semantic_kernel.connectors.openapi_plugin.server_url_validator import ServerUrlValidationOptions
from semantic_kernel.exceptions import FunctionExecutionException
def test_build_full_url():
runner = OpenApiRunner({})
base_url = "http://example.com"
query_string = "param1=value1&param2=value2"
expected_url = "http://example.com?param1=value1&param2=value2"
assert runner.build_full_url(base_url, query_string) == expected_url
def test_build_operation_url():
runner = OpenApiRunner({})
operation = MagicMock()
operation.build_operation_url.return_value = "http://example.com"
operation.build_query_string.return_value = "param1=value1"
arguments = {}
expected_url = "http://example.com?param1=value1"
assert runner.build_operation_url(operation, arguments) == expected_url
def test_build_json_payload_dynamic_payload():
runner = OpenApiRunner({}, enable_dynamic_payload=True)
mock_property1 = Mock()
mock_property2 = Mock()
mock_property1.freeze = MagicMock()
mock_property2.freeze = MagicMock()
payload_metadata = RestApiPayload(
media_type="application/json",
properties=[mock_property1, mock_property2],
description=None,
schema=None,
)
arguments = {"property1": "value1", "property2": "value2"}
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)
assert content == '{"property1": "value1", "property2": "value2"}'
assert media_type == "application/json"
def test_build_json_payload_no_metadata():
runner = OpenApiRunner({}, enable_dynamic_payload=True)
arguments = {}
with pytest.raises(
FunctionExecutionException, match="Payload can't be built dynamically due to the missing payload metadata."
):
runner.build_json_payload(None, arguments)
def test_build_json_payload_static_payload():
runner = OpenApiRunner({}, enable_dynamic_payload=False)
arguments = {runner.payload_argument_name: '{"key": "value"}'}
content, media_type = runner.build_json_payload(None, arguments)
assert content == '{"key": "value"}'
assert media_type == '{"key": "value"}'
def test_build_json_payload_no_payload():
runner = OpenApiRunner({}, enable_dynamic_payload=False)
arguments = {}
with pytest.raises(
FunctionExecutionException, match=f"No payload is provided by the argument '{runner.payload_argument_name}'."
):
runner.build_json_payload(None, arguments)
def test_build_json_object():
runner = OpenApiRunner({})
properties = [MagicMock()]
properties[0].name = "prop1"
properties[0].type = "string"
properties[0].is_required = True
properties[0].properties = []
arguments = {"prop1": "value1"}
result = runner.build_json_object(properties, arguments)
assert result == {"prop1": "value1"}
def test_build_json_object_missing_required_argument():
runner = OpenApiRunner({})
properties = [MagicMock()]
properties[0].name = "prop1"
properties[0].type = "string"
properties[0].is_required = True
properties[0].properties = []
arguments = {}
with pytest.raises(FunctionExecutionException, match="No argument is found for the 'prop1' payload property."):
runner.build_json_object(properties, arguments)
def test_build_json_object_recursive():
runner = OpenApiRunner({})
nested_property1 = Mock()
nested_property1.name = "property1.nested_property1"
nested_property1.type = "string"
nested_property1.is_required = True
nested_property1.properties = []
nested_property2 = Mock()
nested_property2.name = "property2.nested_property2"
nested_property2.type = "integer"
nested_property2.is_required = False
nested_property2.properties = []
nested_properties = [nested_property1, nested_property2]
property1 = Mock()
property1.name = "property1"
property1.type = "object"
property1.properties = nested_properties
property1.is_required = True
property2 = Mock()
property2.name = "property2"
property2.type = "string"
property2.is_required = False
property2.properties = []
properties = [property1, property2]
arguments = {
"property1.nested_property1": "nested_value1",
"property1.nested_property2": 123,
"property2": "value2",
}
result = runner.build_json_object(properties, arguments)
expected_result = {"property1": {"property1.nested_property1": "nested_value1"}, "property2": "value2"}
assert result == expected_result
def test_build_json_object_recursive_missing_required_argument():
runner = OpenApiRunner({})
nested_property1 = MagicMock()
nested_property1.name = "nested_property1"
nested_property1.type = "string"
nested_property1.is_required = True
nested_property2 = MagicMock()
nested_property2.name = "nested_property2"
nested_property2.type = "integer"
nested_property2.is_required = False
nested_properties = [nested_property1, nested_property2]
property1 = MagicMock()
property1.name = "property1"
property1.type = "object"
property1.properties = nested_properties
property1.is_required = True
property2 = MagicMock()
property2.name = "property2"
property2.type = "string"
property2.is_required = False
properties = [property1, property2]
arguments = {
"property1.nested_property2": 123,
"property2": "value2",
}
with pytest.raises(
FunctionExecutionException, match="No argument is found for the 'nested_property1' payload property."
):
runner.build_json_object(properties, arguments)
def test_build_operation_payload_no_request_body():
runner = OpenApiRunner({})
operation = MagicMock()
operation.request_body = None
arguments = {}
assert runner.build_operation_payload(operation, arguments) == (None, None)
def test_get_argument_name_for_payload_no_namespacing():
runner = OpenApiRunner({}, enable_payload_namespacing=False)
assert runner.get_argument_name_for_payload("prop1") == "prop1"
def test_get_argument_name_for_payload_with_namespacing():
runner = OpenApiRunner({}, enable_payload_namespacing=True)
assert runner.get_argument_name_for_payload("prop1", "namespace") == "namespace.prop1"
def test_build_operation_payload_with_request_body():
runner = OpenApiRunner({})
request_body = RestApiPayload(
media_type="application/json",
properties=["property1", "property2"],
description=None,
schema=None,
)
operation = Mock(spec=RestApiOperation)
operation.request_body = request_body
arguments = {"property1": "value1", "property2": "value2"}
runner.build_json_payload = MagicMock(
return_value=('{"property1": "value1", "property2": "value2"}', "application/json")
)
payload, media_type = runner.build_operation_payload(operation, arguments)
runner.build_json_payload.assert_called_once_with(request_body, arguments)
assert payload == '{"property1": "value1", "property2": "value2"}'
assert media_type == "application/json"
def test_build_operation_payload_without_request_body():
runner = OpenApiRunner({})
operation = Mock(spec=RestApiOperation)
operation.request_body = None
arguments = {runner.payload_argument_name: '{"property1": "value1"}'}
runner.build_json_payload = MagicMock(return_value=('{"property1": "value1"}', "application/json"))
payload, media_type = runner.build_operation_payload(operation, arguments)
runner.build_json_payload.assert_not_called()
assert payload is None
assert media_type is None
def test_build_operation_payload_no_request_body_no_payload_argument():
runner = OpenApiRunner({})
operation = Mock(spec=RestApiOperation)
operation.request_body = None
arguments = {}
payload, media_type = runner.build_operation_payload(operation, arguments)
assert payload is None
assert media_type is None
def test_get_first_response_media_type():
runner = OpenApiRunner({})
responses = OrderedDict()
response = MagicMock()
response.media_type = "application/xml"
responses["200"] = response
assert runner._get_first_response_media_type(responses) == "application/xml"
def test_get_first_response_media_type_default():
runner = OpenApiRunner({})
responses = OrderedDict()
assert runner._get_first_response_media_type(responses) == runner.media_type_application_json
async def test_run_operation():
runner = OpenApiRunner(
{}, server_url_validation_options=ServerUrlValidationOptions(allowed_base_urls=["http://example.com"])
)
operation = MagicMock()
arguments = {}
options = MagicMock()
options.server_url_override = None
options.api_host_url = None
operation.build_headers.return_value = {"header": "value"}
operation.method = "GET"
runner.build_operation_url = MagicMock(return_value="http://example.com")
runner.build_operation_payload = MagicMock(return_value=('{"key": "value"}', "application/json"))
response = MagicMock()
response.media_type = "application/json"
operation.responses = OrderedDict([("200", response)])
async def mock_request(*args, **kwargs):
response = MagicMock()
response.text = "response text"
return response
runner.http_client = AsyncMock()
runner.http_client.request = mock_request
runner.auth_callback = AsyncMock(return_value={"Authorization": "Bearer token"})
runner.http_client.headers = {"header": "client-value"}
result = await runner.run_operation(operation, arguments, options)
assert result == "response text"
async def test_run_operation_blocks_disallowed_url_before_request():
runner = OpenApiRunner({})
operation = MagicMock()
operation.method = "GET"
runner.build_operation_url = MagicMock(return_value="https://127.0.0.1/latest/meta-data/")
runner.http_client = AsyncMock()
runner.http_client.request = AsyncMock()
with pytest.raises(FunctionExecutionException, match="loopback"):
await runner.run_operation(operation, {}, None)
operation.build_headers.assert_not_called()
runner.http_client.request.assert_not_called()
async def test_run_operation_blocks_server_variable_ssrf_before_request():
runner = OpenApiRunner({})
operation = RestApiOperation(
id="getCloudMetadata",
method="GET",
servers=[
{
"url": "https://{api_server}/",
"variables": {"api_server": {"default": "api.example.com"}},
}
],
path="latest/meta-data/",
)
runner.http_client = AsyncMock()
runner.http_client.request = AsyncMock()
with pytest.raises(FunctionExecutionException, match="link-local"):
await runner.run_operation(operation, {"api_server": "169.254.169.254"}, None)
runner.http_client.request.assert_not_called()
@@ -0,0 +1,22 @@
# Copyright (c) Microsoft. All rights reserved.
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"
timeout = 30.0
rest_api_operation_run_options = RestApiRunOptions(server_url_override, api_host_url, timeout)
assert rest_api_operation_run_options.server_url_override == server_url_override
assert rest_api_operation_run_options.api_host_url == api_host_url
assert rest_api_operation_run_options.timeout == timeout
def test_initialization_no_params():
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
@@ -0,0 +1,30 @@
# Copyright (c) Microsoft. All rights reserved.
from semantic_kernel.connectors.openapi_plugin.models.rest_api_uri import Uri
def test_uri_initialization():
test_uri = "https://example.com/path?query=param"
uri_instance = Uri(test_uri)
assert uri_instance.uri == test_uri
def test_get_left_part():
test_uri = "https://example.com/path?query=param"
expected_left_part = "https://example.com"
uri_instance = Uri(test_uri)
assert uri_instance.get_left_part() == expected_left_part
def test_get_left_part_no_scheme():
test_uri = "example.com/path?query=param"
expected_left_part = "://"
uri_instance = Uri(test_uri)
assert uri_instance.get_left_part() == expected_left_part
def test_get_left_part_no_netloc():
test_uri = "https:///path?query=param"
expected_left_part = "https://"
uri_instance = Uri(test_uri)
assert uri_instance.get_left_part() == expected_left_part
@@ -0,0 +1,170 @@
# Copyright (c) Microsoft. All rights reserved.
import socket
import pytest
from semantic_kernel.connectors.openapi_plugin.server_url_validator import (
ServerUrlValidationOptions,
try_categorize_non_public_address,
validate_server_url,
)
from semantic_kernel.exceptions import FunctionExecutionException
@pytest.mark.parametrize(
("address", "expected_category"),
[
("127.0.0.1", "loopback"),
("127.255.255.254", "loopback"),
("169.254.169.254", "link-local"),
("169.254.0.1", "link-local"),
("10.0.0.1", "private (RFC1918)"),
("172.16.0.1", "private (RFC1918)"),
("172.31.255.255", "private (RFC1918)"),
("192.168.0.1", "private (RFC1918)"),
("100.64.0.1", "carrier-grade NAT"),
("100.127.255.254", "carrier-grade NAT"),
("0.0.0.0", "unspecified"),
("224.0.0.1", "multicast"),
("239.255.255.255", "multicast"),
("240.0.0.1", "reserved"),
("255.255.255.255", "reserved"),
("198.18.0.1", "benchmarking"),
("192.0.2.1", "reserved"),
("198.51.100.1", "reserved"),
("203.0.113.1", "reserved"),
("::1", "loopback"),
("::", "unspecified"),
("fe80::1", "link-local"),
("fc00::1", "private (IPv6 ULA)"),
("fd00::1", "private (IPv6 ULA)"),
("ff02::1", "multicast"),
("2001:db8::1", "reserved"),
("::ffff:127.0.0.1", "loopback"),
("::ffff:169.254.169.254", "link-local"),
],
)
def test_try_categorize_non_public_address(address: str, expected_category: str):
blocked, category = try_categorize_non_public_address(address)
assert blocked is True
assert category == expected_category
@pytest.mark.parametrize(
"address",
[
"8.8.8.8",
"1.1.1.1",
"93.184.216.34",
"172.15.255.255",
"172.32.0.1",
"11.0.0.1",
"192.169.0.1",
"100.63.255.255",
"100.128.0.1",
"2606:4700:4700::1111",
],
)
def test_try_categorize_non_public_address_allows_public_addresses(address: str):
blocked, category = try_categorize_non_public_address(address)
assert blocked is False
assert category == ""
async def test_validate_server_url_rejects_literal_link_local_ipv4():
with pytest.raises(FunctionExecutionException, match="link-local"):
await validate_server_url("https://169.254.169.254/latest/meta-data/")
async def test_validate_server_url_rejects_literal_loopback_ipv6():
with pytest.raises(FunctionExecutionException, match="loopback"):
await validate_server_url("https://[::1]/")
async def test_validate_server_url_rejects_http_scheme_by_default():
with pytest.raises(FunctionExecutionException, match="scheme"):
await validate_server_url("http://api.example.com/")
async def test_validate_server_url_allows_public_https_literal_by_default():
await validate_server_url("https://1.1.1.1/")
async def test_validate_server_url_rejects_invalid_uri_with_function_execution_exception():
with pytest.raises(FunctionExecutionException, match="not a valid absolute URI"):
await validate_server_url("invalid_url")
async def test_validate_server_url_allows_explicit_base_url_for_private_http_address():
options = ServerUrlValidationOptions(allowed_base_urls=["http://192.168.1.100/v1"])
await validate_server_url("http://192.168.1.100/v1/orders", options)
async def test_validate_server_url_rejects_when_allowed_base_urls_do_not_match():
options = ServerUrlValidationOptions(allowed_base_urls=["https://api.example.com/v1"])
with pytest.raises(FunctionExecutionException, match="allowed base URLs"):
await validate_server_url("https://api.example.com/v2/orders", options)
async def test_validate_server_url_allows_private_network_access_after_scheme_gate():
options = ServerUrlValidationOptions(allow_private_network_access=True)
await validate_server_url("https://10.0.0.5/", options)
async def test_validate_server_url_blocks_hostname_resolving_to_link_local():
async def fake_resolver(host: str):
assert host == "evil.example.com"
return ["169.254.169.254"]
with pytest.raises(FunctionExecutionException, match="link-local"):
await validate_server_url("https://evil.example.com/latest/meta-data/", dns_resolver=fake_resolver)
async def test_validate_server_url_blocks_hostname_resolving_to_loopback():
async def fake_resolver(host: str):
assert host == "attacker.example.com"
return ["127.0.0.1"]
with pytest.raises(FunctionExecutionException, match="loopback"):
await validate_server_url("https://attacker.example.com/api", dns_resolver=fake_resolver)
async def test_validate_server_url_blocks_when_any_resolved_address_is_private():
async def fake_resolver(host: str):
assert host == "rebind.example.com"
return ["93.184.216.34", "10.0.0.1"]
with pytest.raises(FunctionExecutionException, match="private"):
await validate_server_url("https://rebind.example.com/", dns_resolver=fake_resolver)
async def test_validate_server_url_allows_hostname_resolving_to_public_ip():
async def fake_resolver(host: str):
assert host == "api.example.com"
return ["93.184.216.34"]
await validate_server_url("https://api.example.com/", dns_resolver=fake_resolver)
async def test_validate_server_url_blocks_dns_resolution_failure():
async def fake_resolver(host: str):
assert host == "unreachable.example.com"
raise socket.gaierror()
with pytest.raises(FunctionExecutionException, match="DNS resolution"):
await validate_server_url("https://unreachable.example.com/", dns_resolver=fake_resolver)
async def test_validate_server_url_blocks_empty_dns_response():
async def fake_resolver(host: str):
assert host == "empty-dns.example.com"
return []
with pytest.raises(FunctionExecutionException, match="returned no addresses"):
await validate_server_url("https://empty-dns.example.com/", dns_resolver=fake_resolver)
File diff suppressed because it is too large Load Diff