mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-19 20:14:17 +02:00
Respect OpenAPI content type in request body serialization (#3611)
This commit is contained in:
parent
6f4df1eac4
commit
ca76b82878
2 changed files with 204 additions and 13 deletions
|
|
@ -1,5 +1,6 @@
|
|||
"""Request director using openapi-core for stateless HTTP request building."""
|
||||
|
||||
import json as _json
|
||||
from typing import Any, ClassVar
|
||||
from urllib.parse import quote, urljoin
|
||||
|
||||
|
|
@ -74,14 +75,32 @@ class RequestDirector:
|
|||
json_body: dict[str, Any] | list[Any] | None = None
|
||||
content: str | bytes | None = None
|
||||
|
||||
# Step 5: Handle request body
|
||||
# Step 5: Determine the declared content type from the OpenAPI spec
|
||||
declared_content_type: str | None = None
|
||||
if route.request_body and route.request_body.content_schema:
|
||||
declared_content_type = next(iter(route.request_body.content_schema))
|
||||
|
||||
# Step 6: Handle request body
|
||||
if body is not None:
|
||||
if isinstance(body, dict | list):
|
||||
json_body = body
|
||||
if (
|
||||
declared_content_type is not None
|
||||
and declared_content_type != "application/json"
|
||||
and "json" in declared_content_type
|
||||
):
|
||||
# JSON-compatible types like application/json-patch+json
|
||||
# or application/merge-patch+json need an explicit
|
||||
# Content-Type header since httpx's json= always
|
||||
# sets application/json.
|
||||
content = _json.dumps(body, allow_nan=False).encode("utf-8")
|
||||
headers = dict(headers) if headers else {}
|
||||
headers["Content-Type"] = declared_content_type
|
||||
else:
|
||||
json_body = body
|
||||
else:
|
||||
content = body
|
||||
|
||||
# Step 6: Create httpx.Request
|
||||
# Step 7: Create httpx.Request
|
||||
return httpx.Request(
|
||||
method=method,
|
||||
url=url,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Unit tests for RequestDirector."""
|
||||
|
||||
import json
|
||||
from urllib.parse import unquote
|
||||
|
||||
import pytest
|
||||
|
|
@ -196,8 +197,6 @@ class TestRequestDirector:
|
|||
) # 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"
|
||||
|
|
@ -217,8 +216,6 @@ class TestRequestDirector:
|
|||
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"
|
||||
|
|
@ -242,8 +239,6 @@ class TestRequestDirector:
|
|||
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
|
||||
|
|
@ -311,8 +306,6 @@ class TestRequestDirector:
|
|||
assert request.method == "POST"
|
||||
assert "123" in str(request.url)
|
||||
|
||||
import json
|
||||
|
||||
body_data = json.loads(request.content)
|
||||
assert body_data["name"] == "John Doe"
|
||||
|
||||
|
|
@ -379,12 +372,191 @@ class TestRequestDirector:
|
|||
|
||||
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 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_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_falls_through(self, director):
|
||||
"""Non-JSON types like multipart/form-data don't get JSON-serialized."""
|
||||
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")
|
||||
# Should fall through to httpx's json= path (not manually serialized
|
||||
# with a multipart/form-data header), since the content type isn't
|
||||
# JSON-compatible.
|
||||
assert request.headers["content-type"] == "application/json"
|
||||
|
||||
|
||||
class TestQueryParameterSerialization:
|
||||
"""Test that query parameters respect OpenAPI explode/style settings."""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue