Skip to content

Commit 7270225

Browse files
committed
fix: Send a POST request as application/json when body is a Python dictionary (#160)
1 parent 4ea0682 commit 7270225

5 files changed

Lines changed: 148 additions & 28 deletions

File tree

src/meatie/internal/template/request.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,11 @@ def build_request(self, *args: Any, **kwargs: Any) -> Request:
123123

124124
if param.kind == Kind.BODY:
125125
if param.formatter is not None:
126-
body_data = param.formatter(value)
126+
raw_value = param.formatter(value)
127+
if isinstance(raw_value, (str, bytes)):
128+
body_data = raw_value
129+
else:
130+
body_json = raw_value
127131
else:
128132
body_json = self.request_encoder.to_content(value)
129133
continue

tests/client/aiohttp/adapter/test_pydantic_v2.py

Lines changed: 28 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,54 @@
11
# Copyright 2024 The Meatie Authors. All rights reserved.
22
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.
3-
import json
43
from http import HTTPStatus
5-
from http.server import BaseHTTPRequestHandler
6-
from typing import Annotated, Any, Optional, Union
4+
from typing import Annotated, Union
75

86
import pytest
97
from aiohttp import ClientSession
108
from http_test import Handler, HTTPTestServer
9+
from http_test.handlers import echo_json_handler
1110
from typing_extensions import Literal
1211

13-
from meatie import AsyncResponse, api_ref, endpoint
12+
from meatie import api_ref, endpoint
1413
from meatie_aiohttp import Client
1514

1615
pydantic = pytest.importorskip("pydantic", minversion="2.0.0")
1716

1817

19-
@pytest.mark.asyncio()
20-
async def test_post_request_body_with_fmt(http_server: HTTPTestServer) -> None:
21-
# GIVEN
22-
class Request(pydantic.BaseModel):
23-
data: Optional[dict[str, Any]]
18+
class Todo(pydantic.BaseModel):
19+
user_id: int = pydantic.Field(alias="userId")
20+
id: int
21+
title: str
22+
completed: bool
2423

25-
def handler(request: BaseHTTPRequestHandler) -> None:
26-
content_length = request.headers.get("Content-Length", "0")
27-
raw_body = request.rfile.read(int(content_length))
28-
body = json.loads(raw_body.decode("utf-8"))
2924

30-
if body.get("data") is not None:
31-
request.send_response(HTTPStatus.BAD_REQUEST)
32-
else:
33-
request.send_response(HTTPStatus.OK)
34-
request.end_headers()
25+
class Params:
26+
@staticmethod
27+
def todo(value: Todo) -> dict:
28+
return value.model_dump(by_alias=True)
3529

36-
http_server.handler = handler
3730

38-
def dump_body(model: pydantic.BaseModel) -> str:
39-
return model.model_dump_json(by_alias=True, exclude_none=True)
31+
class JsonPlaceholderClient(Client):
32+
def __init__(self, base_url: str) -> None:
33+
super().__init__(ClientSession(base_url=base_url))
4034

41-
class TestClient(Client):
42-
@endpoint("/")
43-
async def post_request(self, body: Annotated[Request, api_ref(fmt=dump_body)]) -> AsyncResponse: ...
35+
@endpoint("/todos")
36+
async def post_todo_as_dict(
37+
self, todo: Annotated[Todo, api_ref("body", fmt=lambda data: data.model_dump(by_alias=True))]
38+
) -> Todo: ...
39+
40+
41+
@pytest.mark.asyncio()
42+
async def test_post_request_body_with_fmt_as_dict(http_server: HTTPTestServer) -> None:
43+
# GIVEN
44+
http_server.handler = echo_json_handler
4445

4546
# WHEN
46-
async with TestClient(ClientSession(http_server.base_url)) as client:
47-
response = await client.post_request(Request(data=None))
47+
async with JsonPlaceholderClient(http_server.base_url) as client:
48+
todo = await client.post_todo_as_dict(Todo(userId=123, id=456, title="abc", completed=True))
4849

4950
# THEN
50-
assert response.status == HTTPStatus.OK
51+
assert todo.user_id == 123
5152

5253

5354
class TodoV1(pydantic.BaseModel):
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright 2024 The Meatie Authors. All rights reserved.
2+
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.
3+
from typing import Annotated
4+
5+
import pytest
6+
from http_test import HTTPTestServer
7+
from http_test.handlers import echo_json_handler
8+
from httpx import Client as HttpxClient
9+
10+
from meatie import api_ref, endpoint
11+
from meatie_httpx import Client
12+
13+
pydantic = pytest.importorskip("pydantic", minversion="2.0.0")
14+
15+
16+
class Todo(pydantic.BaseModel):
17+
user_id: int = pydantic.Field(alias="userId")
18+
id: int
19+
title: str
20+
completed: bool
21+
22+
23+
class Params:
24+
@staticmethod
25+
def todo(value: Todo) -> dict:
26+
return value.model_dump(by_alias=True)
27+
28+
29+
class JsonPlaceholderClient(Client):
30+
def __init__(self, base_url: str) -> None:
31+
super().__init__(HttpxClient(base_url=base_url))
32+
33+
@endpoint("/todos")
34+
def post_todo_as_dict(
35+
self, todo: Annotated[Todo, api_ref("body", fmt=lambda data: data.model_dump(by_alias=True))]
36+
) -> Todo: ...
37+
38+
39+
def test_post_request_body_with_fmt_as_dict(http_server: HTTPTestServer) -> None:
40+
# GIVEN
41+
http_server.handler = echo_json_handler
42+
43+
# WHEN
44+
with JsonPlaceholderClient(http_server.base_url) as client:
45+
todo = client.post_todo_as_dict(Todo(userId=123, id=456, title="abc", completed=True))
46+
47+
# THEN
48+
assert todo.user_id == 123
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright 2024 The Meatie Authors. All rights reserved.
2+
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.
3+
from typing import Annotated
4+
5+
import pytest
6+
from http_test import HTTPTestServer
7+
from http_test.handlers import echo_json_handler
8+
from requests import Session
9+
10+
from meatie import api_ref, endpoint
11+
from meatie_requests import Client
12+
13+
pydantic = pytest.importorskip("pydantic", minversion="2.0.0")
14+
15+
16+
class Todo(pydantic.BaseModel):
17+
user_id: int = pydantic.Field(alias="userId")
18+
id: int
19+
title: str
20+
completed: bool
21+
22+
23+
class Params:
24+
@staticmethod
25+
def todo(value: Todo) -> dict:
26+
return value.model_dump(by_alias=True)
27+
28+
29+
class JsonPlaceholderClient(Client):
30+
def __init__(self, base_url: str) -> None:
31+
super().__init__(Session(), prefix=base_url)
32+
33+
@endpoint("/todos")
34+
def post_todo_as_dict(
35+
self, todo: Annotated[Todo, api_ref("body", fmt=lambda data: data.model_dump(by_alias=True))]
36+
) -> Todo: ...
37+
38+
39+
def test_post_request_body_with_fmt_as_dict(http_server: HTTPTestServer) -> None:
40+
# GIVEN
41+
http_server.handler = echo_json_handler
42+
43+
# WHEN
44+
with JsonPlaceholderClient(http_server.base_url) as client:
45+
todo = client.post_todo_as_dict(Todo(userId=123, id=456, title="abc", completed=True))
46+
47+
# THEN
48+
assert todo.user_id == 123

tests/shared/http_test/handlers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from decimal import Decimal
66
from http import HTTPStatus
77
from http.server import BaseHTTPRequestHandler, SimpleHTTPRequestHandler
8+
from json import JSONDecodeError
89
from typing import Any, Callable, Optional
910
from urllib.parse import parse_qs, urlparse
1011

@@ -91,6 +92,24 @@ def echo_handler(handler: Handler) -> None:
9192
handler.send_bytes(HTTPStatus.OK, content_type, raw_body_opt)
9293

9394

95+
def echo_json_handler(handler: Handler) -> None:
96+
content_type = handler.headers.get("Content-Type")
97+
raw_body = handler.safe_text()
98+
if content_type != "application/json" or raw_body is None:
99+
handler.send_response(HTTPStatus.BAD_REQUEST)
100+
handler.end_headers()
101+
return
102+
103+
try:
104+
body = json.loads(raw_body)
105+
except JSONDecodeError:
106+
handler.send_response(HTTPStatus.BAD_REQUEST)
107+
handler.end_headers()
108+
return
109+
110+
handler.send_json(HTTPStatus.OK, body)
111+
112+
94113
def diagnostic_handler(handler: Handler) -> None:
95114
body_opt = handler.safe_text()
96115
if body_opt is None:

0 commit comments

Comments
 (0)