chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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¶m2=value2"
|
||||
expected_url = "http://example.com?param1=value1¶m2=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
Reference in New Issue
Block a user