diff --git a/pyproject.toml b/pyproject.toml index 406541c..8aafbf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,4 +76,13 @@ exclude = [ "target", "*.egg-info", ".git", - ] +] + +[tool.coverage.report] +exclude_lines = [ + "if __name__ == .__main__.:", + "raise NotImplemented.", + "return NotImplemented", + "def __repr__", + "...", +] diff --git a/rolo/dispatcher.py b/rolo/dispatcher.py index a309d80..05772b0 100644 --- a/rolo/dispatcher.py +++ b/rolo/dispatcher.py @@ -72,9 +72,6 @@ def __call__(self, request: Request, **kwargs) -> ResultValue: def _try_parse_pydantic_request_body(request: Request, endpoint: Handler) -> Optional[dict]: - if not request.content_length: - return None - if not inspect.isfunction(endpoint) and not inspect.ismethod(endpoint): # cannot yet dispatch to other callables (e.g. an object with a `__call__` method) return None @@ -92,6 +89,10 @@ def _try_parse_pydantic_request_body(request: Request, endpoint: Handler) -> Opt if arg_type is None: return None + if not request.content_length: + # forces a Validation error "Invalid JSON: EOF while parsing a value at line 1 column 0" + arg_type.model_validate_json(b"") + # TODO: error handling obj = request.get_json(force=True) diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index a5749ec..b9f9954 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -1,6 +1,5 @@ from typing import Any, Dict -import pydantic import pytest from werkzeug.exceptions import NotFound @@ -81,65 +80,3 @@ def handler(_request: Request): router.add("/", handler) assert router.dispatch(Request("GET", "/")).status_code == 200 - - -class TestPydanticHandlerDispatcher: - def test_request_arg(self): - router = Router(dispatcher=handler_dispatcher()) - - class MyItem(pydantic.BaseModel): - name: str - price: float - is_offer: bool = None - - def handler(_request: Request, item_id: int, item: MyItem) -> str: - return item.model_dump_json() - - router.add("/items/", handler) - - request = Request("POST", "/items/123", body=b'{"name":"rolo","price":420.69}') - assert router.dispatch(request).data == b'{"name":"rolo","price":420.69,"is_offer":null}' - - def test_response(self): - router = Router(dispatcher=handler_dispatcher()) - - class MyItem(pydantic.BaseModel): - name: str - price: float - is_offer: bool = None - - def handler(_request: Request, item_id: int) -> MyItem: - return MyItem(name="rolo", price=420.69) - - router.add("/items/", handler) - - request = Request("GET", "/items/123") - assert router.dispatch(request).get_json() == { - "name": "rolo", - "price": 420.69, - "is_offer": None, - } - - def test_request_arg_validation_error(self): - router = Router(dispatcher=handler_dispatcher()) - - class MyItem(pydantic.BaseModel): - name: str - price: float - is_offer: bool = None - - def handler(_request: Request, item_id: int, item: MyItem) -> str: - return item.model_dump_json() - - router.add("/items/", handler) - - request = Request("POST", "/items/123", body=b'{"name":"rolo"}') - assert router.dispatch(request).get_json() == [ - { - "type": "missing", - "loc": ["price"], - "msg": "Field required", - "input": {"name": "rolo"}, - "url": "https://errors.pydantic.dev/2.8/v/missing", - } - ] diff --git a/tests/test_pydantic.py b/tests/test_pydantic.py new file mode 100644 index 0000000..ebed2c3 --- /dev/null +++ b/tests/test_pydantic.py @@ -0,0 +1,157 @@ +import pydantic +import pytest + +from rolo import Request, Router, dispatcher, resource +from rolo.dispatcher import handler_dispatcher + + +class MyItem(pydantic.BaseModel): + name: str + price: float + is_offer: bool = None + + +class TestPydanticHandlerDispatcher: + def test_request_arg(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, item: MyItem) -> dict: + return {"item": item.model_dump()} + + router.add("/items", handler) + + request = Request("POST", "/items", body=b'{"name":"rolo","price":420.69}') + assert router.dispatch(request).get_json(force=True) == { + "item": { + "name": "rolo", + "price": 420.69, + "is_offer": None, + }, + } + + def test_request_args(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, item_id: int, item: MyItem) -> dict: + return {"item_id": item_id, "item": item.model_dump()} + + router.add("/items/", handler) + + request = Request("POST", "/items/123", body=b'{"name":"rolo","price":420.69}') + assert router.dispatch(request).get_json(force=True) == { + "item_id": "123", + "item": { + "name": "rolo", + "price": 420.69, + "is_offer": None, + }, + } + + def test_request_args_empty_body(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, item_id: int, item: MyItem) -> dict: + return {"item_id": item_id, "item": item.model_dump()} + + router.add("/items/", handler) + + request = Request("POST", "/items/123", body=b"") + assert router.dispatch(request).get_json(force=True) == [ + { + "type": "json_invalid", + "loc": [], + "msg": "Invalid JSON: EOF while parsing a value at line 1 column 0", + "ctx": {"error": "EOF while parsing a value at line 1 column 0"}, + "input": "", + "url": "https://errors.pydantic.dev/2.8/v/json_invalid", + } + ] + + def test_response(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, item_id: int) -> MyItem: + return MyItem(name="rolo", price=420.69) + + router.add("/items/", handler) + + request = Request("GET", "/items/123") + assert router.dispatch(request).get_json() == { + "name": "rolo", + "price": 420.69, + "is_offer": None, + } + + def test_request_arg_validation_error(self): + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, item_id: int, item: MyItem) -> str: + return item.model_dump_json() + + router.add("/items/", handler) + + request = Request("POST", "/items/123", body=b'{"name":"rolo"}') + assert router.dispatch(request).get_json() == [ + { + "type": "missing", + "loc": ["price"], + "msg": "Field required", + "input": {"name": "rolo"}, + "url": "https://errors.pydantic.dev/2.8/v/missing", + } + ] + + def test_missing_annotation(self): + router = Router(dispatcher=handler_dispatcher()) + + # without an annotation, we cannot be sure what type to serialize into, so the dispatcher doesn't pass + # anything into ``item``. + def handler(_request: Request, item=None) -> dict: + return {"item": item} + + router.add("/items", handler) + + request = Request("POST", "/items", body=b'{"name":"rolo","price":420.69}') + assert router.dispatch(request).get_json(force=True) == {"item": None} + + def test_with_pydantic_disabled(self, monkeypatch): + monkeypatch.setattr(dispatcher, "ENABLE_PYDANTIC", False) + router = Router(dispatcher=handler_dispatcher()) + + def handler(_request: Request, item: MyItem) -> dict: + return {"item": item.model_dump()} + + router.add("/items", handler) + + request = Request("POST", "/items", body=b'{"name":"rolo","price":420.69}') + with pytest.raises(TypeError): + # "missing 1 required positional argument: 'item'" + assert router.dispatch(request) + + def test_with_resource(self): + router = Router(dispatcher=handler_dispatcher()) + + @resource("/items/") + class MyResource: + def on_get(self, request: Request, item_id: int): + return MyItem(name="rolo", price=420.69) + + def on_post(self, request: Request, item_id: int, item: MyItem): + return {"item_id": item_id, "item": item.model_dump()} + + router.add(MyResource()) + + response = router.dispatch(Request("GET", "/items/123")) + assert response.get_json() == { + "name": "rolo", + "price": 420.69, + "is_offer": None, + } + + response = router.dispatch( + Request("POST", "/items/123", body=b'{"name":"rolo","price":420.69}') + ) + assert response.get_json() == { + "item": {"is_offer": None, "name": "rolo", "price": 420.69}, + "item_id": "123", + }