feat(request): add get_query_string_as_media() method#2548
feat(request): add get_query_string_as_media() method#2548MannXo wants to merge 15 commits intofalconry:masterfrom
Conversation
Add new method to deserialize entire query string as media content.
This supports OpenAPI 3.2 Parameter Object with content specification,
where the query string is treated as serialized data (e.g., JSON).
The method:
- URL-decodes the query string
- Deserializes using configured media handlers
- Caches results for performance
- Supports both WSGI and ASGI (async) variants
- Includes default_when_empty parameter for graceful error handling
Example usage:
# Query string: ?%7B%22numbers%22%3A%5B1%2C2%5D%7D
data = req.get_query_string_as_media('application/json')
# data == {'numbers': [1, 2]}
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2548 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 64 64
Lines 7875 7890 +15
Branches 1078 1081 +3
=========================================
+ Hits 7875 7890 +15 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Add comprehensive edge case tests to achieve 100% coverage: - test_cached_error_with_default_when_empty: Verify cached error returns default on subsequent call - test_cached_error_reraises_without_default: Verify cached error is re-raised without default These tests cover the error caching branches where the method is called multiple times after an error has been cached, both with and without the default_when_empty parameter. Total: 40 tests (20 WSGI + 20 ASGI)
Use the synchronous handler.deserialize() instead of deserialize_async() when processing query strings, since the data is already in memory as a string. This avoids type checking errors related to BytesIO not being compatible with AsyncReadableIO, and is more appropriate since no async I/O is needed for in-memory data.
vytas7
left a comment
There was a problem hiding this comment.
Thanks for this PR, it looks like an already very advanced implementation! 💯
However, as explained inline, we don't want to perform advanced caching of the deserialized media, at least not in the first iteration, given that this is a rather niche feature (it has been introduced in OpenAPI 3.2.0, previously this scenario was impossible to describe in OAS).
There was a problem hiding this comment.
Although we love good documentation, and this looks like an excellent walkthrough, maybe it devotes too much attention to this rather niche feature. 🤔
Moreover, is this script included anywhere?
There was a problem hiding this comment.
Removed query_string_as_media_example.py for now. Can add back later if/when caching is implemented.
falcon/asgi/request.py
Outdated
| _cached_uri: str | None = None | ||
| _media: UnsetOr[Any] = _UNSET | ||
| _media_error: Exception | None = None | ||
| _query_string_media: UnsetOr[Any] = _UNSET |
There was a problem hiding this comment.
This is a rather new niche feature, that is unlikely to be widely used, at least not directly after release.
So let's not allocate new requests attributes for caching it, simply deserialize it every time anew. It is unlikely to be called many times.
There was a problem hiding this comment.
Removed _query_string_media and _query_string_media_error slots. Method now deserializes fresh on each call.
falcon/asgi/request.py
Outdated
| # memory as a string | ||
| from io import BytesIO | ||
|
|
||
| query_stream = BytesIO(decoded_query_string.encode('utf-8')) |
There was a problem hiding this comment.
Although Falcon is performance-oriented, as pointed out above, this method is going to be a rather niche feature, so maybe it would be possible to DRY the ASGI branch by calling the sync method to do the heavy lifting?
We could possibly just document that the synchronous handler is always used in this case, even for ASGI.
There was a problem hiding this comment.
Done. ASGI implementation now delegates to parent: return request.Request.get_query_string_as_media(self, media_type, default_when_empty
Thank you @vytas7 for the thorough review and your feedback. I have made some changes, to address your comments. Ready for the next round of review. |
falcon/request.py
Outdated
| self._media: UnsetOr[Any] = _UNSET | ||
| self._media_error: Exception | None = None | ||
| self._query_string_media: UnsetOr[Any] = _UNSET | ||
| self._query_string_media_error: Exception | None = None |
There was a problem hiding this comment.
I guess we can now remove these
There was a problem hiding this comment.
Done. Removed _query_string_media and _query_string_media_error
falcon/request.py
Outdated
|
|
||
| try: | ||
| return handler.deserialize( | ||
| query_stream, media_type, len(decoded_query_string.encode('utf-8')) |
There was a problem hiding this comment.
we could avoid this re-encoding by saving the bytes before creating the bytesio
There was a problem hiding this comment.
Good catch. We now store query_bytes = decoded_query_string.encode('utf-8') and reuse it for BytesIO and len() to avoid double-encoding.
|
|
||
| assert resource.captured_media == {'emoji': '🚀', 'chinese': '你好'} | ||
|
|
||
| def test_error_caching(self, asgi, client): |
There was a problem hiding this comment.
this test is likely outdated since caching was removed
There was a problem hiding this comment.
Updated the test comment to reflect current behavior (removed references to caching); the test logic remains and still passes.
|
|
||
| assert resource.captured_media == {'uses': 'default'} | ||
|
|
||
| def test_cached_error_with_default_when_empty(self, asgi, client): |
There was a problem hiding this comment.
here too let's just remove the comments regarding the cache, the test if fine to have even with the current impl
There was a problem hiding this comment.
Removed outdated cache comments and kept the test. It continues to validate error + default behavior
|
great work, thanks! |
7ea7c17 to
8cbae6b
Compare
Thank you for the review and comments. I have pushed a new commit. Ready for the next round of review. |
8cbae6b to
5633332
Compare
vytas7
left a comment
There was a problem hiding this comment.
Hi again @MannXo, and sorry for taking time to respond to this one.
Your PR looks good for most of it, but IMHO we should remove the async version of the method, plus make a couple of other adjustments before merging (see inline).
|
|
||
|
|
||
| @pytest.fixture | ||
| def client(asgi): |
There was a problem hiding this comment.
I don't think this is needed, the util fixture can do this for you.
|
Thank you for the comments @vytas7 I pushed a change including the following: P.S, The |
Edit: still not 100% why we started getting this only now, but it is fun that it actually uncovers a real issue. When using |
falcon/request.py
Outdated
| handler, _, _ = self.options.media_handlers._resolve( | ||
| media_type, self.options.default_media_type | ||
| ) | ||
| except errors.HTTPUnsupportedMediaType: |
There was a problem hiding this comment.
Not critical for the first iteration, but it would be nice to optimize this slightly by passing raise_not_found=False to the _resolve method, and handling the case where handler is None instead. It should be much cheaper than raising one extra time unnecessarily.
| resource = CaptureQueryStringMedia() | ||
| app = util.create_app(asgi) | ||
| app.add_route('/test', resource) | ||
| client = testing.TestClient(app) |
There was a problem hiding this comment.
Could we DRY this block along the below lines (untested)? ⬇️
@pytest.fixture()
def client(asgi, util):
resource = CaptureQueryStringMedia()
app = util.create_app(asgi)
app.add_route('/test', resource)
return testing.TestClient(app)
CaselIT
left a comment
There was a problem hiding this comment.
thanks for the change!
I looks mostly ok to me, I just just document the error if the media is missing and dry up a bit the tests
| media_type, self.options.default_media_type, raise_not_found=False | ||
| ) | ||
| if handler is None: | ||
| raise ValueError( |
There was a problem hiding this comment.
let's mention in the doc string that a value error is raised if no handler is found
There was a problem hiding this comment.
Added Raises section
| client.simulate_get('/test', query_string=query_string) | ||
|
|
||
| # URLEncodedFormHandler should parse this | ||
| assert isinstance(resource.captured_media, dict) |
There was a problem hiding this comment.
we could also / instead compare the value
|
|
||
| assert resource.captured_media == {'emoji': '🚀', 'chinese': '你好'} | ||
|
|
||
| def test_error_caching(self, asgi, util): |
There was a problem hiding this comment.
not sure if this test is useful. it's fine to keep though
|
|
||
| assert resource.captured_media == [1, 2, 3, 4, 5] | ||
|
|
||
| def test_boolean_and_null_values(self, standard_client): |
There was a problem hiding this comment.
I would remove, we aren't testing a json serializer here
| 'empty': None, | ||
| } | ||
|
|
||
| def test_numeric_values(self, standard_client): |
|
|
||
| assert resource.error_message == 'Custom error' | ||
|
|
||
| def test_uses_default_media_type_when_none_specified(self, asgi, util): |
There was a problem hiding this comment.
same as test_simple_json_query_string, would remove
Add new method to deserialize entire query string as media content. This supports OpenAPI 3.2 Parameter Object with content specification, where the query string is treated as serialized data (e.g., JSON).
The method:
Example usage:
# Query string: ?%7B%22numbers%22%3A%5B1%2C2%5D%7D data = req.get_query_string_as_media('application/json') # data == {'numbers': [1, 2]}Closes #2546.
Summary of Changes
Replace this text with a high-level summary of the changes included in this PR.
Related Issues
Please reference here any issue #'s that are relevant to this PR, or simply enter "N/A" if this PR does not relate to any existing issues.
Pull Request Checklist
This is just a reminder about the most common mistakes. Please make sure that you tick all appropriate boxes. Reading our contribution guide at least once will save you a few review cycles!
If an item doesn't apply to your pull request, check it anyway to make it apparent that there's nothing to do.
tox.docs/.docs/.versionadded,versionchanged, ordeprecateddirectives.docs/_newsfragments/, with the file name format{issue_number}.{fragment_type}.rst. (Runtox -e towncrier, and inspectdocs/_build/html/changes/in the browser to ensure it renders correctly.)If you have any questions to any of the points above, just submit and ask! This checklist is here to help you, not to deter you from contributing!
PR template inspired by the attrs project.