Skip to content

Commit 763b348

Browse files
authored
Merge pull request #303 from python-ellar/response_tuple
chores: Response Model Tuple Refactor
2 parents 2b77ce5 + cff8140 commit 763b348

File tree

5 files changed

+77
-10
lines changed

5 files changed

+77
-10
lines changed

docs/techniques/response-model.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,73 @@ They include:
129129
- `model_field_or_schema`: `Optional` property. For return data validation. Default: `None` **Optional**
130130

131131

132+
## **Response Resolution Process**
133+
134+
When a route handler returns a response, Ellar goes through a resolution process to determine which response model to use.
135+
This process is handled by the `response_resolver` method and follows these steps:
136+
137+
### **1. Determine the Status Code**
138+
139+
The status code is determined in the following priority order:
140+
141+
1. **Single Model Case**: If only one response model is defined, its status code is used as the default.
142+
```python
143+
@get("/item", response=UserSchema) # Defaults to status code 200
144+
def get_item(self):
145+
return dict(username='Ellar')
146+
```
147+
148+
2. **Response Object Status Code**: If a Response object was created and has a status code set, that takes precedence.
149+
```python
150+
@get("/item", response={200: UserSchema, 201: UserSchema})
151+
def get_item(self, res: Response):
152+
res.status_code = 201 # This status code will be used
153+
return dict(username='Ellar')
154+
```
155+
156+
3. **Tuple Return Value**: If the handler returns a tuple of `(response_obj, status_code)`, the status code from the tuple is used.
157+
```python
158+
@get("/item", response={200: UserSchema, 201: UserSchema})
159+
def get_item(self):
160+
return dict(username='Ellar'), 201 # Returns with status code 201
161+
```
162+
163+
### **2. Match to Response Model**
164+
165+
After determining the status code, Ellar matches it to the appropriate response model:
166+
167+
1. **Exact Match**: If a response model is defined for the specific status code, it's used.
168+
2. **Ellipsis Fallback**: If no exact match is found but an `Ellipsis` (`...`) key exists, that model is used as a catch-all.
169+
3. **Default Fallback**: If no match is found, `EmptyAPIResponseModel` is used with a warning logged.
170+
171+
Example with Ellipsis fallback:
172+
173+
```python
174+
from ellar.common import Controller, get, ControllerBase, Serializer
175+
176+
class UserSchema(Serializer):
177+
username: str
178+
email: str = None
179+
180+
class ErrorSchema(Serializer):
181+
message: str
182+
code: int
183+
184+
@Controller
185+
class ItemsController(ControllerBase):
186+
@get("/item", response={200: UserSchema, ...: ErrorSchema})
187+
def get_item(self, status: int):
188+
if status == 200:
189+
return dict(username='Ellar', email='[email protected]')
190+
# Any other status code will use ErrorSchema
191+
return dict(message='Error occurred', code=status), status
192+
```
193+
194+
In this example, returning with status code 200 uses `UserSchema`, but any other status code (404, 500, etc.) will use `ErrorSchema` as the fallback.
195+
196+
!!! tip
197+
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.
198+
132199
## **Different Response Models**
133200
Let's see different `ResponseModel` available in Ellar and how you can create one too.
134201

ellar/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Ellar - Python ASGI web framework for building fast, efficient, and scalable RESTful APIs and server-side applications."""
22

3-
__version__ = "0.9.3"
3+
__version__ = "0.9.4"

ellar/common/responses/models/route.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def response_resolver(
7676
status_code = http_connection.get_response().status_code
7777

7878
if isinstance(response_obj, tuple) and len(response_obj) == 2:
79-
status_code, response_obj = endpoint_response_content
79+
response_obj, status_code = endpoint_response_content
8080

8181
if status_code in self.models:
8282
response_model = self.models[status_code]

tests/test_response/test_defined_response_model.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,22 @@ def get_validlist():
3636

3737
@mr.get("/items/valid_tuple_return", response={200: List[Item], 201: Item})
3838
def get_valid_tuple_return():
39-
return 201, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}
39+
return {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 201
4040

4141

4242
@mr.get("/items/not_found_res_model", response={200: List[Item], 201: Item})
4343
def get_not_found_res_model():
44-
return 301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}
44+
return {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301
4545

4646

4747
@mr.get("/items/text-case-1", response=PlainTextResponse)
4848
def get_plain_text_case_1():
49-
return '301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}'
49+
return '{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301'
5050

5151

5252
@mr.get("/items/text-case-2", response={200: PlainTextResponse, 201: Item})
5353
def get_plain_text_case_2():
54-
return '301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}'
54+
return '{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301'
5555

5656

5757
@mr.get("/items/text-case-3", response={200: PlainTextResponse})
@@ -107,14 +107,14 @@ def test_plain_test_case_1(test_client_factory):
107107
response = client.get("/items/text-case-1")
108108
response.raise_for_status()
109109
assert "text/plain" in str(response.headers["content-type"])
110-
assert response.text == '301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}'
110+
assert response.text == '{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301'
111111

112112

113113
def test_plain_test_case_2(test_client_factory):
114114
client = test_client_factory(app)
115115
response = client.get("/items/text-case-2")
116116
assert "text/plain" in str(response.headers["content-type"])
117-
assert response.text == '301, {"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}'
117+
assert response.text == '{"name": "baz", "price": 2.0, "owner_ids": [1, 2, 3]}, 301'
118118

119119

120120
def test_sent_without_response_model_response(test_client_factory):

tests/test_response/test_pydantic_response_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,10 @@ def get_valid_ellipsis(switch: common.Query[str]):
8989
Item(aliased_name="bar", price=1.0),
9090
Item(aliased_name="bar2", price=2.0),
9191
]
92-
return 201, {
92+
return {
9393
"k1": ItemSerializer(aliased_name="foo"),
9494
"k3": ItemSerializer(aliased_name="baz", price=2.0, owner_ids=[1, 2, 3]),
95-
}
95+
}, 201
9696

9797

9898
app = AppFactory.create_app(routers=(mr,))

0 commit comments

Comments
 (0)