mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-12 16:49:10 +02:00
462 lines
16 KiB
Python
462 lines
16 KiB
Python
"""Unit tests for RequestDirector."""
|
|
|
|
import pytest
|
|
from jsonschema_path import SchemaPath
|
|
|
|
from fastmcp.experimental.utilities.openapi.director import RequestDirector
|
|
from fastmcp.experimental.utilities.openapi.models import (
|
|
HTTPRoute,
|
|
ParameterInfo,
|
|
RequestBodyInfo,
|
|
)
|
|
from fastmcp.experimental.utilities.openapi.parser import parse_openapi_to_http_routes
|
|
|
|
|
|
class TestRequestDirector:
|
|
"""Test RequestDirector request building functionality."""
|
|
|
|
@pytest.fixture
|
|
def basic_route(self):
|
|
"""Create a basic HTTPRoute for testing."""
|
|
return HTTPRoute(
|
|
path="/users/{id}",
|
|
method="GET",
|
|
operation_id="get_user",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "integer"},
|
|
)
|
|
],
|
|
flat_param_schema={
|
|
"type": "object",
|
|
"properties": {"id": {"type": "integer"}},
|
|
"required": ["id"],
|
|
},
|
|
parameter_map={"id": {"location": "path", "openapi_name": "id"}},
|
|
)
|
|
|
|
@pytest.fixture
|
|
def complex_route(self):
|
|
"""Create a complex HTTPRoute with multiple parameter types."""
|
|
return HTTPRoute(
|
|
path="/items/{id}",
|
|
method="PATCH",
|
|
operation_id="update_item",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "string"},
|
|
),
|
|
ParameterInfo(
|
|
name="version",
|
|
location="query",
|
|
required=False,
|
|
schema={"type": "integer", "default": 1},
|
|
),
|
|
ParameterInfo(
|
|
name="X-Client-Version",
|
|
location="header",
|
|
required=False,
|
|
schema={"type": "string"},
|
|
),
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"title": {"type": "string"},
|
|
"description": {"type": "string"},
|
|
},
|
|
"required": ["title"],
|
|
}
|
|
},
|
|
),
|
|
flat_param_schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "string"},
|
|
"version": {"type": "integer", "default": 1},
|
|
"X-Client-Version": {"type": "string"},
|
|
"title": {"type": "string"},
|
|
"description": {"type": "string"},
|
|
},
|
|
"required": ["id", "title"],
|
|
},
|
|
parameter_map={
|
|
"id": {"location": "path", "openapi_name": "id"},
|
|
"version": {"location": "query", "openapi_name": "version"},
|
|
"X-Client-Version": {
|
|
"location": "header",
|
|
"openapi_name": "X-Client-Version",
|
|
},
|
|
"title": {"location": "body", "openapi_name": "title"},
|
|
"description": {"location": "body", "openapi_name": "description"},
|
|
},
|
|
)
|
|
|
|
@pytest.fixture
|
|
def collision_route(self):
|
|
"""Create a route with parameter name collisions."""
|
|
return HTTPRoute(
|
|
path="/users/{id}",
|
|
method="PUT",
|
|
operation_id="update_user",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "integer"},
|
|
)
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"type": "integer"},
|
|
"name": {"type": "string"},
|
|
},
|
|
"required": ["name"],
|
|
}
|
|
},
|
|
),
|
|
flat_param_schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"id__path": {"type": "integer"},
|
|
"id": {"type": "integer"},
|
|
"name": {"type": "string"},
|
|
},
|
|
"required": ["id__path", "name"],
|
|
},
|
|
parameter_map={
|
|
"id__path": {"location": "path", "openapi_name": "id"},
|
|
"id": {"location": "body", "openapi_name": "id"},
|
|
"name": {"location": "body", "openapi_name": "name"},
|
|
},
|
|
)
|
|
|
|
@pytest.fixture
|
|
def director(self, basic_openapi_30_spec):
|
|
"""Create a RequestDirector instance."""
|
|
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
|
return RequestDirector(spec)
|
|
|
|
def test_director_initialization(self, basic_openapi_30_spec):
|
|
"""Test RequestDirector initialization."""
|
|
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
|
director = RequestDirector(spec)
|
|
|
|
assert director._spec is not None
|
|
assert director._spec == spec
|
|
|
|
def test_build_basic_request(self, director, basic_route):
|
|
"""Test building a basic GET request with path parameter."""
|
|
flat_args = {"id": 123}
|
|
|
|
request = director.build(basic_route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "GET"
|
|
assert request.url == "https://api.example.com/users/123"
|
|
assert (
|
|
request.content == b""
|
|
) # httpx.Request sets content to empty bytes for GET
|
|
|
|
def test_build_complex_request(self, director, complex_route):
|
|
"""Test building a complex request with multiple parameter types."""
|
|
flat_args = {
|
|
"id": "item123",
|
|
"version": 2,
|
|
"X-Client-Version": "1.0.0",
|
|
"title": "Updated Title",
|
|
"description": "Updated description",
|
|
}
|
|
|
|
request = director.build(complex_route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "PATCH"
|
|
assert "item123" in str(request.url)
|
|
assert "version=2" in str(request.url)
|
|
|
|
# Check headers
|
|
headers = dict(request.headers) if request.headers else {}
|
|
assert (
|
|
headers.get("x-client-version") == "1.0.0"
|
|
) # httpx normalizes headers to lowercase
|
|
|
|
# Check body
|
|
import json
|
|
|
|
assert request.content is not None
|
|
body_data = json.loads(request.content)
|
|
assert body_data["title"] == "Updated Title"
|
|
assert body_data["description"] == "Updated description"
|
|
|
|
def test_build_request_with_collisions(self, director, collision_route):
|
|
"""Test building request with parameter name collisions."""
|
|
flat_args = {
|
|
"id__path": 123, # Path parameter
|
|
"id": 456, # Body parameter
|
|
"name": "John Doe",
|
|
}
|
|
|
|
request = director.build(collision_route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "PUT"
|
|
assert "123" in str(request.url) # Path ID should be 123
|
|
|
|
# Check body
|
|
import json
|
|
|
|
body_data = json.loads(request.content)
|
|
assert body_data["id"] == 456 # Body ID should be 456
|
|
assert body_data["name"] == "John Doe"
|
|
|
|
def test_build_request_with_none_values(self, director, complex_route):
|
|
"""Test that None values are skipped for optional parameters."""
|
|
flat_args = {
|
|
"id": "item123",
|
|
"version": None, # Optional, should be skipped
|
|
"X-Client-Version": None, # Optional, should be skipped
|
|
"title": "Required Title",
|
|
"description": None, # Optional body param, should be skipped
|
|
}
|
|
|
|
request = director.build(complex_route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "PATCH"
|
|
assert "item123" in str(request.url)
|
|
assert "version" not in str(request.url) # Should not include None version
|
|
|
|
headers = dict(request.headers) if request.headers else {}
|
|
assert "X-Client-Version" not in headers
|
|
|
|
import json
|
|
|
|
body_data = json.loads(request.content)
|
|
assert body_data["title"] == "Required Title"
|
|
assert "description" not in body_data # Should not include None description
|
|
|
|
def test_build_request_fallback_mapping(self, director):
|
|
"""Test fallback parameter mapping when parameter_map is not available."""
|
|
# Create route without parameter_map
|
|
route_without_map = HTTPRoute(
|
|
path="/users/{id}",
|
|
method="GET",
|
|
operation_id="get_user",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "integer"},
|
|
)
|
|
],
|
|
# No parameter_map provided
|
|
)
|
|
|
|
flat_args = {"id": 123}
|
|
|
|
request = director.build(
|
|
route_without_map, flat_args, "https://api.example.com"
|
|
)
|
|
|
|
assert request.method == "GET"
|
|
assert "123" in str(request.url)
|
|
|
|
def test_build_request_suffixed_parameters(self, director):
|
|
"""Test handling of suffixed parameters in fallback mode."""
|
|
route = HTTPRoute(
|
|
path="/users/{id}",
|
|
method="POST",
|
|
operation_id="create_user",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "integer"},
|
|
)
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
}
|
|
},
|
|
),
|
|
)
|
|
|
|
# Use suffixed parameter names
|
|
flat_args = {
|
|
"id__path": 123,
|
|
"name": "John Doe",
|
|
}
|
|
|
|
request = director.build(route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "POST"
|
|
assert "123" in str(request.url)
|
|
|
|
import json
|
|
|
|
body_data = json.loads(request.content)
|
|
assert body_data["name"] == "John Doe"
|
|
|
|
def test_url_building(self, director, basic_route):
|
|
"""Test URL building with different base URLs."""
|
|
flat_args = {"id": 123}
|
|
|
|
# Test with trailing slash
|
|
request1 = director.build(basic_route, flat_args, "https://api.example.com/")
|
|
assert request1.url == "https://api.example.com/users/123"
|
|
|
|
# Test without trailing slash
|
|
request2 = director.build(basic_route, flat_args, "https://api.example.com")
|
|
assert request2.url == "https://api.example.com/users/123"
|
|
|
|
# Test with path in base URL
|
|
request3 = director.build(basic_route, flat_args, "https://api.example.com/v1")
|
|
assert request3.url == "https://api.example.com/v1/users/123"
|
|
|
|
def test_body_construction_single_value(self, director):
|
|
"""Test body construction when body schema is not an object."""
|
|
route = HTTPRoute(
|
|
path="/upload",
|
|
method="POST",
|
|
operation_id="upload_file",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={"text/plain": {"type": "string"}},
|
|
),
|
|
parameter_map={
|
|
"content": {"location": "body", "openapi_name": "content"},
|
|
},
|
|
)
|
|
|
|
flat_args = {"content": "Hello, World!"}
|
|
|
|
request = director.build(route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "POST"
|
|
# For non-JSON content, httpx uses 'content' parameter which becomes bytes
|
|
assert request.content == b"Hello, World!"
|
|
|
|
def test_body_construction_multiple_properties_non_object_schema(self, director):
|
|
"""Test body construction with multiple properties but non-object schema."""
|
|
route = HTTPRoute(
|
|
path="/complex",
|
|
method="POST",
|
|
operation_id="complex_op",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {"type": "string"} # Non-object schema
|
|
},
|
|
),
|
|
parameter_map={
|
|
"prop1": {"location": "body", "openapi_name": "prop1"},
|
|
"prop2": {"location": "body", "openapi_name": "prop2"},
|
|
},
|
|
)
|
|
|
|
flat_args = {"prop1": "value1", "prop2": "value2"}
|
|
|
|
request = director.build(route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "POST"
|
|
# Should wrap in object when multiple properties but schema is not object
|
|
import json
|
|
|
|
body_data = json.loads(request.content)
|
|
assert body_data == {"prop1": "value1", "prop2": "value2"}
|
|
|
|
|
|
class TestRequestDirectorIntegration:
|
|
"""Test RequestDirector with real parsed routes."""
|
|
|
|
def test_with_parsed_routes(self, basic_openapi_30_spec):
|
|
"""Test RequestDirector with routes parsed from real spec."""
|
|
routes = parse_openapi_to_http_routes(basic_openapi_30_spec)
|
|
assert len(routes) == 1
|
|
|
|
route = routes[0]
|
|
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
|
director = RequestDirector(spec)
|
|
|
|
flat_args = {"id": 42}
|
|
request = director.build(route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "GET"
|
|
assert request.url == "https://api.example.com/users/42"
|
|
|
|
def test_with_collision_spec(self, collision_spec):
|
|
"""Test RequestDirector with collision spec."""
|
|
routes = parse_openapi_to_http_routes(collision_spec)
|
|
assert len(routes) == 1
|
|
|
|
route = routes[0]
|
|
spec = SchemaPath.from_dict(collision_spec)
|
|
director = RequestDirector(spec)
|
|
|
|
# Use the parameter names from the actual parameter map
|
|
param_map = route.parameter_map
|
|
path_param_name = None
|
|
body_param_names = []
|
|
|
|
for param_name, mapping in param_map.items():
|
|
if mapping["location"] == "path" and mapping["openapi_name"] == "id":
|
|
path_param_name = param_name
|
|
elif mapping["location"] == "body":
|
|
body_param_names.append(param_name)
|
|
|
|
assert path_param_name is not None
|
|
|
|
flat_args = {path_param_name: 123, "name": "John Doe"}
|
|
# Add body id if it exists in the parameter map
|
|
for param_name in body_param_names:
|
|
if "id" in param_name:
|
|
flat_args[param_name] = 456
|
|
|
|
request = director.build(route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "PUT"
|
|
assert "123" in str(request.url)
|
|
|
|
def test_with_deepobject_spec(self, deepobject_spec):
|
|
"""Test RequestDirector with deepObject parameters."""
|
|
routes = parse_openapi_to_http_routes(deepobject_spec)
|
|
assert len(routes) == 1
|
|
|
|
route = routes[0]
|
|
spec = SchemaPath.from_dict(deepobject_spec)
|
|
director = RequestDirector(spec)
|
|
|
|
# DeepObject parameters should be flattened in the parameter map
|
|
flat_args = {}
|
|
for param_name in route.parameter_map.keys():
|
|
if "filter" in param_name:
|
|
# Set some test values based on parameter name
|
|
if "category" in param_name:
|
|
flat_args[param_name] = "electronics"
|
|
elif "min" in param_name:
|
|
flat_args[param_name] = 10.0
|
|
elif "max" in param_name:
|
|
flat_args[param_name] = 100.0
|
|
|
|
if flat_args: # Only test if we have parameters to test with
|
|
request = director.build(route, flat_args, "https://api.example.com")
|
|
|
|
assert request.method == "GET"
|
|
assert str(request.url).startswith("https://api.example.com/search")
|