From 4a208105198ce236049ae1394034b65e92fbf03f Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Thu, 20 Nov 2025 14:12:37 +0100 Subject: [PATCH 1/2] documented response model tuple form --- docs/techniques/response-model.md | 67 +++++++++++++++++++ ellar/common/responses/models/route.py | 2 +- .../test_defined_response_model.py | 12 ++-- .../test_pydantic_response_model.py | 4 +- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/docs/techniques/response-model.md b/docs/techniques/response-model.md index 4c2e9dc1..0315a88e 100644 --- a/docs/techniques/response-model.md +++ b/docs/techniques/response-model.md @@ -129,6 +129,73 @@ They include: - `model_field_or_schema`: `Optional` property. For return data validation. Default: `None` **Optional** +## **Response Resolution Process** + +When a route handler returns a response, Ellar goes through a resolution process to determine which response model to use. +This process is handled by the `response_resolver` method and follows these steps: + +### **1. Determine the Status Code** + +The status code is determined in the following priority order: + +1. **Single Model Case**: If only one response model is defined, its status code is used as the default. + ```python + @get("/item", response=UserSchema) # Defaults to status code 200 + def get_item(self): + return dict(username='Ellar') + ``` + +2. **Response Object Status Code**: If a Response object was created and has a status code set, that takes precedence. + ```python + @get("/item", response={200: UserSchema, 201: UserSchema}) + def get_item(self, res: Response): + res.status_code = 201 # This status code will be used + return dict(username='Ellar') + ``` + +3. **Tuple Return Value**: If the handler returns a tuple of `(response_obj, status_code)`, the status code from the tuple is used. + ```python + @get("/item", response={200: UserSchema, 201: UserSchema}) + def get_item(self): + return dict(username='Ellar'), 201 # Returns with status code 201 + ``` + +### **2. Match to Response Model** + +After determining the status code, Ellar matches it to the appropriate response model: + +1. **Exact Match**: If a response model is defined for the specific status code, it's used. +2. **Ellipsis Fallback**: If no exact match is found but an `Ellipsis` (`...`) key exists, that model is used as a catch-all. +3. **Default Fallback**: If no match is found, `EmptyAPIResponseModel` is used with a warning logged. + +Example with Ellipsis fallback: + +```python +from ellar.common import Controller, get, ControllerBase, Serializer + +class UserSchema(Serializer): + username: str + email: str = None + +class ErrorSchema(Serializer): + message: str + code: int + +@Controller +class ItemsController(ControllerBase): + @get("/item", response={200: UserSchema, ...: ErrorSchema}) + def get_item(self, status: int): + if status == 200: + return dict(username='Ellar', email='ellar@example.com') + # Any other status code will use ErrorSchema + return dict(message='Error occurred', code=status), status +``` + +In this example, returning with status code 200 uses `UserSchema`, but any other status code (404, 500, etc.) will use `ErrorSchema` as the fallback. + +!!! tip + Using the `Ellipsis` (`...`) key is useful when you want to define a catch-all response model for various status codes (like error responses) without defining each one explicitly. + ## **Different Response Models** Let's see different `ResponseModel` available in Ellar and how you can create one too. diff --git a/ellar/common/responses/models/route.py b/ellar/common/responses/models/route.py index 8e28cb85..d78ba969 100644 --- a/ellar/common/responses/models/route.py +++ b/ellar/common/responses/models/route.py @@ -76,7 +76,7 @@ def response_resolver( status_code = http_connection.get_response().status_code if isinstance(response_obj, tuple) and len(response_obj) == 2: - status_code, response_obj = endpoint_response_content + response_obj, status_code = endpoint_response_content if status_code in self.models: response_model = self.models[status_code] diff --git a/tests/test_response/test_defined_response_model.py b/tests/test_response/test_defined_response_model.py index 81e3a1d4..1415ac42 100644 --- a/tests/test_response/test_defined_response_model.py +++ b/tests/test_response/test_defined_response_model.py @@ -36,22 +36,22 @@ def get_validlist(): @mr.get("/items/valid_tuple_return", response={200: List[Item], 201: Item}) def get_valid_tuple_return(): - return 201, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]} + return {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 201 @mr.get("/items/not_found_res_model", response={200: List[Item], 201: Item}) def get_not_found_res_model(): - return 301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]} + return {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301 @mr.get("/items/text-case-1", response=PlainTextResponse) def get_plain_text_case_1(): - return '301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}' + return '{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301' @mr.get("/items/text-case-2", response={200: PlainTextResponse, 201: Item}) def get_plain_text_case_2(): - return '301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}' + return '{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301' @mr.get("/items/text-case-3", response={200: PlainTextResponse}) @@ -107,14 +107,14 @@ def test_plain_test_case_1(test_client_factory): response = client.get("/items/text-case-1") response.raise_for_status() assert "text/plain" in str(response.headers["content-type"]) - assert response.text == '301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}' + assert response.text == '{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301' def test_plain_test_case_2(test_client_factory): client = test_client_factory(app) response = client.get("/items/text-case-2") assert "text/plain" in str(response.headers["content-type"]) - assert response.text == '301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}' + assert response.text == '{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301' def test_sent_without_response_model_response(test_client_factory): diff --git a/tests/test_response/test_pydantic_response_model.py b/tests/test_response/test_pydantic_response_model.py index 220f6526..2dc18a10 100644 --- a/tests/test_response/test_pydantic_response_model.py +++ b/tests/test_response/test_pydantic_response_model.py @@ -89,10 +89,10 @@ def get_valid_ellipsis(switch: common.Query[str]): Item(aliased_name="bar", price=1.0), Item(aliased_name="bar2", price=2.0), ] - return 201, { + return { "k1": ItemSerializer(aliased_name="foo"), "k3": ItemSerializer(aliased_name="baz", price=2.0, owner_ids=[1, 2, 3]), - } + }, 201 app = AppFactory.create_app(routers=(mr,)) From cff8140adf0c2eb423327ac23a21da906115e894 Mon Sep 17 00:00:00 2001 From: Ezeudoh Tochukwu Date: Thu, 20 Nov 2025 14:12:54 +0100 Subject: [PATCH 2/2] 0.9.4 --- ellar/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ellar/__init__.py b/ellar/__init__.py index 6d85bffa..b61716d1 100644 --- a/ellar/__init__.py +++ b/ellar/__init__.py @@ -1,3 +1,3 @@ """Ellar - Python ASGI web framework for building fast, efficient, and scalable RESTful APIs and server-side applications.""" -__version__ = "0.9.3" +__version__ = "0.9.4"