Respect OpenAPI content type in request body serialization (#3611)

This commit is contained in:
Jeremiah Lowin 2026-03-24 18:39:37 -04:00 committed by GitHub
commit ca76b82878
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 204 additions and 13 deletions

View file

@ -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,

View file

@ -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."""