60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
1492 lines
53 KiB
Python
1492 lines
53 KiB
Python
"""Unit tests for RequestDirector."""
|
|
|
|
import json
|
|
from urllib.parse import unquote
|
|
|
|
import pytest
|
|
from jsonschema_path import SchemaPath
|
|
|
|
from fastmcp.utilities.openapi.director import RequestDirector
|
|
from fastmcp.utilities.openapi.models import (
|
|
HTTPRoute,
|
|
ParameterInfo,
|
|
RequestBodyInfo,
|
|
)
|
|
from fastmcp.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
|
|
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
|
|
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
|
|
|
|
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)
|
|
|
|
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
|
|
body_data = json.loads(request.content)
|
|
assert body_data == {"prop1": "value1", "prop2": "value2"}
|
|
|
|
|
|
class TestContentTypeHandling:
|
|
"""Test that request Content-Type respects the OpenAPI spec."""
|
|
|
|
@pytest.fixture
|
|
def director(self, basic_openapi_30_spec):
|
|
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
|
return RequestDirector(spec)
|
|
|
|
def test_application_json_uses_httpx_json(self, director):
|
|
"""Standard application/json uses httpx's json= parameter."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="PATCH",
|
|
operation_id="update_item",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"name": {"location": "body", "openapi_name": "name"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"name": "test"}, "https://example.com")
|
|
assert request.headers["content-type"] == "application/json"
|
|
assert json.loads(request.content) == {"name": "test"}
|
|
|
|
def test_json_patch_content_type_preserved(self, director):
|
|
"""application/json-patch+json is sent as the Content-Type header."""
|
|
route = HTTPRoute(
|
|
path="/items/{id}",
|
|
method="PATCH",
|
|
operation_id="patch_item",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "string"},
|
|
),
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json-patch+json": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"op": {"type": "string"},
|
|
"path": {"type": "string"},
|
|
"value": {},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"id": {"location": "path", "openapi_name": "id"},
|
|
"body": {"location": "body", "openapi_name": "body"},
|
|
},
|
|
)
|
|
|
|
patch_ops = [{"op": "replace", "path": "/name", "value": "new-name"}]
|
|
request = director.build(
|
|
route, {"id": "123", "body": patch_ops}, "https://example.com"
|
|
)
|
|
|
|
assert request.headers["content-type"] == "application/json-patch+json"
|
|
assert json.loads(request.content) == patch_ops
|
|
|
|
def test_custom_json_content_type_with_dict_body(self, director):
|
|
"""Any non-standard JSON content type gets the correct header."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="POST",
|
|
operation_id="create_item",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/merge-patch+json": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"name": {"location": "body", "openapi_name": "name"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"name": "test"}, "https://example.com")
|
|
assert request.headers["content-type"] == "application/merge-patch+json"
|
|
assert json.loads(request.content) == {"name": "test"}
|
|
|
|
def test_content_type_preserves_media_type_parameters(self, director):
|
|
"""Media-type parameters like charset are preserved on the wire."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="POST",
|
|
operation_id="create_item",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json-patch+json; charset=utf-8": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"name": {"location": "body", "openapi_name": "name"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"name": "test"}, "https://example.com")
|
|
assert (
|
|
request.headers["content-type"]
|
|
== "application/json-patch+json; charset=utf-8"
|
|
)
|
|
assert json.loads(request.content) == {"name": "test"}
|
|
|
|
def test_custom_content_type_preserves_other_headers(self, director):
|
|
"""Custom content type doesn't clobber other headers from parameters."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="PATCH",
|
|
operation_id="patch_item",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="X-Request-Id",
|
|
location="header",
|
|
required=True,
|
|
schema={"type": "string"},
|
|
),
|
|
],
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json-patch+json": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"X-Request-Id": {
|
|
"location": "header",
|
|
"openapi_name": "X-Request-Id",
|
|
},
|
|
"name": {"location": "body", "openapi_name": "name"},
|
|
},
|
|
)
|
|
|
|
request = director.build(
|
|
route,
|
|
{"X-Request-Id": "abc-123", "name": "test"},
|
|
"https://example.com",
|
|
)
|
|
assert request.headers["content-type"] == "application/json-patch+json"
|
|
assert request.headers["x-request-id"] == "abc-123"
|
|
|
|
def test_no_request_body_info_defaults_to_json(self, director):
|
|
"""When route has no request_body metadata, dict body uses application/json."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="POST",
|
|
operation_id="create_item",
|
|
parameter_map={
|
|
"name": {"location": "body", "openapi_name": "name"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"name": "test"}, "https://example.com")
|
|
assert request.headers["content-type"] == "application/json"
|
|
|
|
def test_non_json_content_type_uses_native_encoding(self, director):
|
|
"""Non-JSON types like multipart/form-data use httpx's native encoding."""
|
|
route = HTTPRoute(
|
|
path="/upload",
|
|
method="POST",
|
|
operation_id="upload",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"multipart/form-data": {
|
|
"type": "object",
|
|
"properties": {"file": {"type": "string"}},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"file": {"location": "body", "openapi_name": "file"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"file": "data"}, "https://example.com")
|
|
# multipart/form-data should be sent as multipart, not JSON
|
|
assert "multipart/form-data" in request.headers["content-type"]
|
|
|
|
|
|
class TestQueryParameterSerialization:
|
|
"""Test that query parameters respect OpenAPI explode/style settings."""
|
|
|
|
@pytest.fixture
|
|
def director(self, basic_openapi_30_spec):
|
|
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
|
return RequestDirector(spec)
|
|
|
|
def test_explode_true_repeats_keys(self, director):
|
|
"""Default behavior: explode=true sends values=a&values=b."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="values",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "array", "items": {"type": "string"}},
|
|
explode=True,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"values": {"location": "query", "openapi_name": "values"},
|
|
},
|
|
)
|
|
|
|
request = director.build(
|
|
route, {"values": ["hello", "world"]}, "https://example.com"
|
|
)
|
|
url = str(request.url)
|
|
assert "values=hello" in url
|
|
assert "values=world" in url
|
|
|
|
def test_explode_false_comma_joins(self, director):
|
|
"""explode=false sends values=hello,world."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="values",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "array", "items": {"type": "string"}},
|
|
explode=False,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"values": {"location": "query", "openapi_name": "values"},
|
|
},
|
|
)
|
|
|
|
request = director.build(
|
|
route, {"values": ["hello", "world"]}, "https://example.com"
|
|
)
|
|
url = str(request.url)
|
|
assert "values=hello%2Cworld" in url or "values=hello,world" in url
|
|
# Must NOT have repeated keys
|
|
assert url.count("values=") == 1
|
|
|
|
def test_explode_none_defaults_to_true(self, director):
|
|
"""When explode is unset, OpenAPI default for form style is explode=true."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="tags",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "array", "items": {"type": "string"}},
|
|
explode=None,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"tags": {"location": "query", "openapi_name": "tags"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"tags": ["a", "b"]}, "https://example.com")
|
|
url = str(request.url)
|
|
assert "tags=a" in url
|
|
assert "tags=b" in url
|
|
|
|
def test_explode_false_with_integers(self, director):
|
|
"""explode=false works with non-string values."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="ids",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "array", "items": {"type": "integer"}},
|
|
explode=False,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"ids": {"location": "query", "openapi_name": "ids"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"ids": [1, 2, 3]}, "https://example.com")
|
|
url = str(request.url)
|
|
assert "ids=1%2C2%2C3" in url or "ids=1,2,3" in url
|
|
assert url.count("ids=") == 1
|
|
|
|
def test_scalar_query_param_unaffected_by_explode(self, director):
|
|
"""Non-list values pass through regardless of explode setting."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="get_item",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="name",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "string"},
|
|
explode=False,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"name": {"location": "query", "openapi_name": "name"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"name": "foo"}, "https://example.com")
|
|
assert "name=foo" in str(request.url)
|
|
|
|
def test_pipe_delimited_explode_false(self, director):
|
|
"""style=pipeDelimited, explode=false sends ids=1|2|3."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="ids",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "array", "items": {"type": "string"}},
|
|
explode=False,
|
|
style="pipeDelimited",
|
|
)
|
|
],
|
|
parameter_map={
|
|
"ids": {"location": "query", "openapi_name": "ids"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"ids": ["1", "2", "3"]}, "https://example.com")
|
|
url = str(request.url)
|
|
assert "ids=1%7C2%7C3" in url or "ids=1|2|3" in url
|
|
assert url.count("ids=") == 1
|
|
|
|
def test_space_delimited_explode_false(self, director):
|
|
"""style=spaceDelimited, explode=false sends ids=1%202%203."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="ids",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "array", "items": {"type": "string"}},
|
|
explode=False,
|
|
style="spaceDelimited",
|
|
)
|
|
],
|
|
parameter_map={
|
|
"ids": {"location": "query", "openapi_name": "ids"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"ids": ["1", "2", "3"]}, "https://example.com")
|
|
url = str(request.url)
|
|
assert "ids=1+2+3" in url or "ids=1%202%203" in url
|
|
assert url.count("ids=") == 1
|
|
|
|
def test_explode_false_booleans_lowercased(self, director):
|
|
"""Booleans serialize as true/false, not True/False."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="flags",
|
|
location="query",
|
|
required=True,
|
|
schema={"type": "array", "items": {"type": "boolean"}},
|
|
explode=False,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"flags": {"location": "query", "openapi_name": "flags"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"flags": [True, False]}, "https://example.com")
|
|
url = str(request.url)
|
|
assert "true" in url and "false" in url
|
|
assert "True" not in url and "False" not in url
|
|
|
|
def test_explode_false_empty_list_omitted(self, director):
|
|
"""Empty list with explode=false omits the parameter entirely."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="ids",
|
|
location="query",
|
|
required=False,
|
|
schema={"type": "array", "items": {"type": "string"}},
|
|
explode=False,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"ids": {"location": "query", "openapi_name": "ids"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"ids": []}, "https://example.com")
|
|
assert "ids" not in str(request.url)
|
|
|
|
def test_explode_false_dict_value(self, director):
|
|
"""style=form, explode=false on objects serializes as key,value pairs."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="color",
|
|
location="query",
|
|
required=True,
|
|
schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"R": {"type": "integer"},
|
|
"G": {"type": "integer"},
|
|
"B": {"type": "integer"},
|
|
},
|
|
},
|
|
explode=False,
|
|
style="form",
|
|
)
|
|
],
|
|
parameter_map={
|
|
"color": {"location": "query", "openapi_name": "color"},
|
|
},
|
|
)
|
|
|
|
request = director.build(
|
|
route,
|
|
{"color": {"R": 100, "G": 200, "B": 150}},
|
|
"https://example.com",
|
|
)
|
|
url = str(request.url)
|
|
assert "color=R" in url
|
|
assert url.count("color=") == 1
|
|
# Should contain alternating key,value pairs
|
|
assert "100" in url and "200" in url and "150" in url
|
|
|
|
def test_explode_true_dict_expands_to_separate_params(self, director):
|
|
"""style=form, explode=true on objects expands each property as a query param."""
|
|
route = HTTPRoute(
|
|
path="/test",
|
|
method="GET",
|
|
operation_id="test_endpoint",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="data",
|
|
location="query",
|
|
required=True,
|
|
schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"myAttribute": {"type": "boolean"},
|
|
},
|
|
},
|
|
explode=True,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"data": {"location": "query", "openapi_name": "data"},
|
|
},
|
|
)
|
|
|
|
request = director.build(
|
|
route, {"data": {"myAttribute": True}}, "https://example.com"
|
|
)
|
|
url = str(request.url)
|
|
# Should expand to myAttribute=true (not data={'myAttribute': True})
|
|
assert "myAttribute=true" in url
|
|
assert "data=" not in url
|
|
|
|
def test_explode_default_dict_expands_to_separate_params(self, director):
|
|
"""Default explode (None → true) on objects expands properties."""
|
|
route = HTTPRoute(
|
|
path="/test",
|
|
method="GET",
|
|
operation_id="test_endpoint",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="filter",
|
|
location="query",
|
|
required=True,
|
|
schema={
|
|
"type": "object",
|
|
"properties": {
|
|
"category": {"type": "string"},
|
|
"active": {"type": "boolean"},
|
|
},
|
|
},
|
|
# explode defaults to None → treated as true
|
|
)
|
|
],
|
|
parameter_map={
|
|
"filter": {"location": "query", "openapi_name": "filter"},
|
|
},
|
|
)
|
|
|
|
request = director.build(
|
|
route,
|
|
{"filter": {"category": "electronics", "active": False}},
|
|
"https://example.com",
|
|
)
|
|
url = str(request.url)
|
|
assert "category=electronics" in url
|
|
assert "active=false" in url
|
|
assert "filter=" not in url
|
|
|
|
def test_explode_true_empty_dict_omitted(self, director):
|
|
"""Empty dict with explode=true omits the parameter."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="filter",
|
|
location="query",
|
|
required=False,
|
|
schema={"type": "object"},
|
|
explode=True,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"filter": {"location": "query", "openapi_name": "filter"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"filter": {}}, "https://example.com")
|
|
assert "filter" not in str(request.url)
|
|
|
|
def test_explode_false_empty_dict_omitted(self, director):
|
|
"""Empty dict with explode=false omits the parameter."""
|
|
route = HTTPRoute(
|
|
path="/items",
|
|
method="GET",
|
|
operation_id="list_items",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="filter",
|
|
location="query",
|
|
required=False,
|
|
schema={"type": "object"},
|
|
explode=False,
|
|
)
|
|
],
|
|
parameter_map={
|
|
"filter": {"location": "query", "openapi_name": "filter"},
|
|
},
|
|
)
|
|
|
|
request = director.build(route, {"filter": {}}, "https://example.com")
|
|
assert "filter" not in str(request.url)
|
|
|
|
|
|
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")
|
|
|
|
|
|
class TestPathTraversalPrevention:
|
|
"""Test that path parameter values are URL-encoded to prevent SSRF/path traversal."""
|
|
|
|
@pytest.fixture
|
|
def director(self, basic_openapi_30_spec):
|
|
spec = SchemaPath.from_dict(basic_openapi_30_spec)
|
|
return RequestDirector(spec)
|
|
|
|
@pytest.fixture
|
|
def path_route(self):
|
|
return HTTPRoute(
|
|
path="/api/v1/users/{id}/profile",
|
|
method="GET",
|
|
operation_id="get_user_profile",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="id",
|
|
location="path",
|
|
required=True,
|
|
schema={"type": "string"},
|
|
)
|
|
],
|
|
flat_param_schema={
|
|
"type": "object",
|
|
"properties": {"id": {"type": "string"}},
|
|
"required": ["id"],
|
|
},
|
|
parameter_map={"id": {"location": "path", "openapi_name": "id"}},
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"malicious_id",
|
|
[
|
|
"../../../admin/delete-all?",
|
|
"../../secret",
|
|
"../../../etc/passwd",
|
|
"foo/../../../admin",
|
|
"..%2F..%2Fadmin",
|
|
"..%2f..%2fadmin",
|
|
],
|
|
)
|
|
def test_path_traversal_encoded(self, director, path_route, malicious_id: str):
|
|
request = director.build(
|
|
path_route, {"id": malicious_id}, "https://api.example.com"
|
|
)
|
|
url = str(request.url)
|
|
assert "/admin" not in url
|
|
assert "/secret" not in url
|
|
assert "/etc/passwd" not in url
|
|
assert url.startswith("https://api.example.com/api/v1/users/")
|
|
|
|
def test_slash_in_param_is_encoded(self, director, path_route):
|
|
request = director.build(path_route, {"id": "a/b"}, "https://api.example.com")
|
|
url = str(request.url)
|
|
assert "/a/b/" not in url
|
|
assert "a%2Fb" in url
|
|
|
|
def test_dot_dot_slash_is_encoded(self, director, path_route):
|
|
request = director.build(
|
|
path_route, {"id": "../admin"}, "https://api.example.com"
|
|
)
|
|
url = str(request.url)
|
|
assert "%2E%2E%2Fadmin" in url or "%2e%2e%2fadmin" in url
|
|
assert url.startswith("https://api.example.com/api/v1/users/")
|
|
|
|
def test_question_mark_encoded(self, director, path_route):
|
|
request = director.build(
|
|
path_route, {"id": "foo?bar=baz"}, "https://api.example.com"
|
|
)
|
|
url = str(request.url)
|
|
assert "foo%3Fbar%3Dbaz" in url or "foo%3fbar%3dbaz" in url
|
|
|
|
def test_hash_encoded(self, director, path_route):
|
|
request = director.build(
|
|
path_route, {"id": "foo#fragment"}, "https://api.example.com"
|
|
)
|
|
url = str(request.url)
|
|
assert "foo%23fragment" in url
|
|
|
|
def test_normal_values_still_work(self, director, path_route):
|
|
request = director.build(
|
|
path_route, {"id": "user-123"}, "https://api.example.com"
|
|
)
|
|
assert (
|
|
str(request.url) == "https://api.example.com/api/v1/users/user-123/profile"
|
|
)
|
|
|
|
def test_dotted_values_encode_dots(self, director, path_route):
|
|
"""Dots are encoded to prevent path normalization by urljoin."""
|
|
request = director.build(
|
|
path_route, {"id": "v1.2.3"}, "https://api.example.com"
|
|
)
|
|
url = str(request.url)
|
|
assert "v1%2E2%2E3" in url
|
|
assert url.startswith("https://api.example.com/api/v1/users/")
|
|
|
|
def test_numeric_values_still_work(self, director, path_route):
|
|
request = director.build(path_route, {"id": 42}, "https://api.example.com")
|
|
assert str(request.url) == "https://api.example.com/api/v1/users/42/profile"
|
|
|
|
def test_bare_single_dot_encoded(self, director, path_route):
|
|
"""Bare '.' must be encoded so urljoin doesn't normalize it away."""
|
|
request = director.build(path_route, {"id": "."}, "https://api.example.com")
|
|
url = str(request.url)
|
|
assert "%2E" in url
|
|
assert url.startswith("https://api.example.com/api/v1/users/")
|
|
|
|
def test_bare_dotdot_encoded(self, director, path_route):
|
|
"""Bare '..' must be encoded so urljoin doesn't resolve it as traversal."""
|
|
request = director.build(path_route, {"id": ".."}, "https://api.example.com")
|
|
url = str(request.url)
|
|
assert "%2E%2E" in url or "%2e%2e" in url
|
|
assert url.startswith("https://api.example.com/api/v1/users/")
|
|
|
|
def test_double_encoded_traversal(self, director, path_route):
|
|
request = director.build(
|
|
path_route,
|
|
{"id": "..%2F..%2Fadmin"},
|
|
"https://api.example.com",
|
|
)
|
|
url = str(request.url)
|
|
decoded = unquote(unquote(url))
|
|
# Verify traversal didn't escape the users/ prefix
|
|
assert decoded.startswith("https://api.example.com/api/v1/users/")
|
|
assert url.startswith("https://api.example.com/api/v1/users/")
|
|
|
|
|
|
class TestContentTypeDispatch:
|
|
"""Test that non-JSON content types are dispatched correctly."""
|
|
|
|
@pytest.fixture
|
|
def director(self):
|
|
spec = SchemaPath.from_dict(
|
|
{
|
|
"openapi": "3.0.0",
|
|
"info": {"title": "t", "version": "1"},
|
|
"paths": {},
|
|
}
|
|
)
|
|
return RequestDirector(spec)
|
|
|
|
@pytest.fixture
|
|
def multipart_route(self):
|
|
return HTTPRoute(
|
|
path="/upload",
|
|
method="POST",
|
|
operation_id="upload_file",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"multipart/form-data": {
|
|
"type": "object",
|
|
"properties": {
|
|
"filename": {"type": "string"},
|
|
"data": {"type": "string"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"filename": {"location": "body", "openapi_name": "filename"},
|
|
"data": {"location": "body", "openapi_name": "data"},
|
|
},
|
|
)
|
|
|
|
@pytest.fixture
|
|
def form_urlencoded_route(self):
|
|
return HTTPRoute(
|
|
path="/login",
|
|
method="POST",
|
|
operation_id="login",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/x-www-form-urlencoded": {
|
|
"type": "object",
|
|
"properties": {
|
|
"username": {"type": "string"},
|
|
"password": {"type": "string"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"username": {"location": "body", "openapi_name": "username"},
|
|
"password": {"location": "body", "openapi_name": "password"},
|
|
},
|
|
)
|
|
|
|
@pytest.fixture
|
|
def json_route(self):
|
|
return HTTPRoute(
|
|
path="/items",
|
|
method="POST",
|
|
operation_id="create_item",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"application/json": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"name": {"location": "body", "openapi_name": "name"},
|
|
},
|
|
)
|
|
|
|
def test_multipart_form_data_not_sent_as_json(self, director, multipart_route):
|
|
"""multipart/form-data bodies should use httpx data= not json=."""
|
|
request = director.build(
|
|
multipart_route,
|
|
{"filename": "test.txt", "data": "hello"},
|
|
)
|
|
content_type = request.headers.get("content-type", "")
|
|
assert "multipart/form-data" in content_type
|
|
# Should NOT be JSON-encoded
|
|
assert "application/json" not in content_type
|
|
|
|
def test_form_urlencoded_not_sent_as_json(self, director, form_urlencoded_route):
|
|
"""application/x-www-form-urlencoded bodies should use httpx data=."""
|
|
request = director.build(
|
|
form_urlencoded_route,
|
|
{"username": "alice", "password": "secret"},
|
|
)
|
|
content_type = request.headers.get("content-type", "")
|
|
assert "application/x-www-form-urlencoded" in content_type
|
|
assert "application/json" not in content_type
|
|
# Verify the body is form-encoded
|
|
body_text = request.content.decode("utf-8")
|
|
assert "username=alice" in body_text
|
|
assert "password=secret" in body_text
|
|
|
|
def test_multipart_stringifies_non_string_values(self, director):
|
|
"""Non-string scalars (int, bool) must be stringified for httpx files=."""
|
|
route = HTTPRoute(
|
|
path="/submit",
|
|
method="POST",
|
|
operation_id="submit",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"multipart/form-data": {
|
|
"type": "object",
|
|
"properties": {
|
|
"count": {"type": "integer"},
|
|
"active": {"type": "boolean"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"count": {"location": "body", "openapi_name": "count"},
|
|
"active": {"location": "body", "openapi_name": "active"},
|
|
},
|
|
)
|
|
# Should not raise — non-string values get str() converted
|
|
request = director.build(route, {"count": 42, "active": True})
|
|
content_type = request.headers.get("content-type", "")
|
|
assert "multipart/form-data" in content_type
|
|
# Multipart requests are streaming; read to verify content
|
|
request.read()
|
|
body = request.content.decode("utf-8")
|
|
assert "42" in body
|
|
assert "true" in body
|
|
|
|
def test_multipart_preserves_tuple_values(self, director):
|
|
"""Tuple values (file-like) are passed through unchanged to httpx files=."""
|
|
route = HTTPRoute(
|
|
path="/upload",
|
|
method="POST",
|
|
operation_id="upload",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"multipart/form-data": {
|
|
"type": "object",
|
|
"properties": {"file": {"type": "string"}},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"file": {"location": "body", "openapi_name": "file"},
|
|
},
|
|
)
|
|
# Tuple values represent file-like objects for httpx
|
|
request = director.build(route, {"file": ("report.csv", b"a,b,c")})
|
|
content_type = request.headers.get("content-type", "")
|
|
assert "multipart/form-data" in content_type
|
|
request.read()
|
|
body = request.content.decode("utf-8", errors="replace")
|
|
assert "report.csv" in body
|
|
assert "a,b,c" in body
|
|
|
|
def test_multipart_bare_bytes_not_stringified(self, director):
|
|
"""Bare bytes values are passed directly to httpx, not repr-stringified."""
|
|
route = HTTPRoute(
|
|
path="/upload",
|
|
method="POST",
|
|
operation_id="upload_binary",
|
|
request_body=RequestBodyInfo(
|
|
required=True,
|
|
content_schema={
|
|
"multipart/form-data": {
|
|
"type": "object",
|
|
"properties": {
|
|
"payload": {"type": "string", "format": "binary"},
|
|
},
|
|
}
|
|
},
|
|
),
|
|
parameter_map={
|
|
"payload": {"location": "body", "openapi_name": "payload"},
|
|
},
|
|
)
|
|
raw = b"\x89PNG\r\n\x1a\nfake-image-data"
|
|
request = director.build(route, {"payload": raw})
|
|
content_type = request.headers.get("content-type", "")
|
|
assert "multipart/form-data" in content_type
|
|
request.read()
|
|
# The raw bytes should appear in the body verbatim, not as "b'\\x89PNG...'"
|
|
assert raw in request.content
|
|
assert b"b'" not in request.content
|
|
|
|
def test_json_body_still_works(self, director, json_route):
|
|
"""application/json bodies should still be sent as JSON (regression)."""
|
|
request = director.build(
|
|
json_route,
|
|
{"name": "widget"},
|
|
)
|
|
content_type = request.headers.get("content-type", "")
|
|
assert "application/json" in content_type
|
|
body = json.loads(request.content.decode("utf-8"))
|
|
assert body == {"name": "widget"}
|
|
|
|
|
|
class TestCookieParameters:
|
|
"""Test that cookie parameters are properly routed."""
|
|
|
|
@pytest.fixture
|
|
def director(self):
|
|
spec = SchemaPath.from_dict(
|
|
{
|
|
"openapi": "3.0.0",
|
|
"info": {"title": "t", "version": "1"},
|
|
"paths": {},
|
|
}
|
|
)
|
|
return RequestDirector(spec)
|
|
|
|
@pytest.fixture
|
|
def cookie_route(self):
|
|
return HTTPRoute(
|
|
path="/dashboard",
|
|
method="GET",
|
|
operation_id="get_dashboard",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="session_id",
|
|
location="cookie",
|
|
required=True,
|
|
schema={"type": "string"},
|
|
),
|
|
],
|
|
parameter_map={
|
|
"session_id": {
|
|
"location": "cookie",
|
|
"openapi_name": "session_id",
|
|
},
|
|
},
|
|
)
|
|
|
|
def test_cookie_parameter_set_on_request(self, director, cookie_route):
|
|
"""Cookie parameters should appear as cookies on the request."""
|
|
request = director.build(
|
|
cookie_route,
|
|
{"session_id": "abc123"},
|
|
)
|
|
assert "session_id" in request.headers.get("cookie", "")
|
|
assert "abc123" in request.headers.get("cookie", "")
|
|
|
|
def test_cookie_parameter_with_fallback_mapping(self, director):
|
|
"""Cookie parameters should work in fallback (no parameter_map) mode."""
|
|
route = HTTPRoute(
|
|
path="/dashboard",
|
|
method="GET",
|
|
operation_id="get_dashboard",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="session_id",
|
|
location="cookie",
|
|
required=True,
|
|
schema={"type": "string"},
|
|
),
|
|
],
|
|
# No parameter_map — triggers fallback path
|
|
)
|
|
request = director.build(
|
|
route,
|
|
{"session_id": "xyz789"},
|
|
)
|
|
assert "session_id" in request.headers.get("cookie", "")
|
|
assert "xyz789" in request.headers.get("cookie", "")
|
|
|
|
def test_cookie_non_string_value_stringified(self, director):
|
|
"""Non-string cookie values (e.g. int, bool) use OpenAPI serialization."""
|
|
route = HTTPRoute(
|
|
path="/api",
|
|
method="GET",
|
|
operation_id="get_api",
|
|
parameters=[
|
|
ParameterInfo(
|
|
name="version",
|
|
location="cookie",
|
|
required=True,
|
|
schema={"type": "integer"},
|
|
),
|
|
ParameterInfo(
|
|
name="debug",
|
|
location="cookie",
|
|
required=False,
|
|
schema={"type": "boolean"},
|
|
),
|
|
],
|
|
parameter_map={
|
|
"version": {"location": "cookie", "openapi_name": "version"},
|
|
"debug": {"location": "cookie", "openapi_name": "debug"},
|
|
},
|
|
)
|
|
request = director.build(route, {"version": 3, "debug": True})
|
|
cookie = request.headers.get("cookie", "")
|
|
assert "version=3" in cookie
|
|
# Booleans use OpenAPI convention (true/false, not True/False)
|
|
assert "debug=true" in cookie
|