Skip to content

Comments

feat(request): add get_query_string_as_media() method#2548

Open
MannXo wants to merge 15 commits intofalconry:masterfrom
MannXo:feat/query-string-as-media
Open

feat(request): add get_query_string_as_media() method#2548
MannXo wants to merge 15 commits intofalconry:masterfrom
MannXo:feat/query-string-as-media

Conversation

@MannXo
Copy link
Contributor

@MannXo MannXo commented Oct 8, 2025

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]}

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.

  • Applied changes to both WSGI and ASGI code paths and interfaces (where applicable).
  • Added tests for changed code.
  • Performed automated tests and code quality checks by running tox.
  • Prefixed code comments with GitHub nick and an appropriate prefix.
  • Coding style is consistent with the rest of the framework.
  • Updated documentation for changed code.
    • Added docstrings for any new classes, functions, or modules.
    • Updated docstrings for any modifications to existing code.
    • Updated both WSGI and ASGI docs (where applicable).
    • Added references to new classes, functions, or modules to the relevant RST file under docs/.
    • Updated all relevant supporting documentation files under docs/.
    • A copyright notice is included at the top of any new modules (using your own name or the name of your organization).
    • Changed/added classes/methods/functions have appropriate versionadded, versionchanged, or deprecated directives.
  • Changes (and possible deprecations) have towncrier news fragments under docs/_newsfragments/, with the file name format {issue_number}.{fragment_type}.rst. (Run tox -e towncrier, and inspect docs/_build/html/changes/ in the browser to ensure it renders correctly.)
  • LLM output, if any, has been carefully reviewed and tested by a human developer. (See also: Use of LLMs ("AI").)

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.

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
Copy link

codecov bot commented Oct 8, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (587bffd) to head (38879b6).
⚠️ Report is 1 commits behind head on master.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

MannXo added 2 commits October 8, 2025 19:26
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.
Copy link
Member

@vytas7 vytas7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed query_string_as_media_example.py for now. Can add back later if/when caching is implemented.

_cached_uri: str | None = None
_media: UnsetOr[Any] = _UNSET
_media_error: Exception | None = None
_query_string_media: UnsetOr[Any] = _UNSET
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed _query_string_media and _query_string_media_error slots. Method now deserializes fresh on each call.

# memory as a string
from io import BytesIO

query_stream = BytesIO(decoded_query_string.encode('utf-8'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. ASGI implementation now delegates to parent: return request.Request.get_query_string_as_media(self, media_type, default_when_empty

@MannXo
Copy link
Contributor Author

MannXo commented Oct 10, 2025

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).

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.

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we can now remove these

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Removed _query_string_media and _query_string_media_error


try:
return handler.deserialize(
query_stream, media_type, len(decoded_query_string.encode('utf-8'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could avoid this re-encoding by saving the bytes before creating the bytesio

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test is likely outdated since caching was removed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here too let's just remove the comments regarding the cache, the test if fine to have even with the current impl

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed outdated cache comments and kept the test. It continues to validate error + default behavior

@CaselIT
Copy link
Member

CaselIT commented Oct 14, 2025

great work, thanks!

@MannXo
Copy link
Contributor Author

MannXo commented Oct 16, 2025

great work, thanks!

Thank you for the review and comments. I have pushed a new commit. Ready for the next round of review.

@MannXo MannXo force-pushed the feat/query-string-as-media branch from 8cbae6b to 5633332 Compare October 16, 2025 09:12
Copy link
Member

@vytas7 vytas7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is needed, the util fixture can do this for you.

@MannXo
Copy link
Contributor Author

MannXo commented Dec 29, 2025

Thank you for the comments @vytas7

I pushed a change including the following:
1- Removed the async method from request.py. Now inherits from parent WSGI class.
2- Removed custom fixture and async resource variations. All 19 tests now use util.create_app() pattern for both ASGI/WSGI.
3- Added try-except in request.py to catch HTTPUnsupportedMediaType and raise ValueError with a suitable message about missing media handler configuration.

P.S, The mypy_tests pipeline failure appears to be a pre-existing issue unrelated to my changes. (I get the same errors running the check on master)

@vytas7
Copy link
Member

vytas7 commented Dec 29, 2025

P.S, The mypy_tests pipeline failure appears to be a pre-existing issue unrelated to my changes. (I get the same errors running the check on master)

Aye, a new Mypy version... I'll try to take a look at that.

Edit: still not 100% why we started getting this only now, but it is fun that it actually uncovers a real issue. When using testtools, Falcon TestCase's patch method is shadowed by testtools.TestCase.patch via diamond inheritance...
(If anyone is wondering what the heck is testtools, see #2156.)

Copy link
Member

@vytas7 vytas7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the prompt updates @MannXo!
A couple more potential improvements (see inline), and I think we can merge this.

handler, _, _ = self.options.media_handlers._resolve(
media_type, self.options.default_media_type
)
except errors.HTTPUnsupportedMediaType:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Comment on lines 23 to 26
resource = CaptureQueryStringMedia()
app = util.create_app(asgi)
app.add_route('/test', resource)
client = testing.TestClient(app)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@vytas7 vytas7 requested a review from CaselIT December 31, 2025 13:45
Copy link
Member

@CaselIT CaselIT left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's mention in the doc string that a value error is raised if no handler is found

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added Raises section

client.simulate_get('/test', query_string=query_string)

# URLEncodedFormHandler should parse this
assert isinstance(resource.captured_media, dict)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could also / instead compare the value

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


assert resource.captured_media == {'emoji': '🚀', 'chinese': '你好'}

def test_error_caching(self, asgi, util):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this test is useful. it's fine to keep though

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kept it for now


assert resource.captured_media == [1, 2, 3, 4, 5]

def test_boolean_and_null_values(self, standard_client):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove, we aren't testing a json serializer here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

'empty': None,
}

def test_numeric_values(self, standard_client):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, would remove

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed


assert resource.error_message == 'Custom error'

def test_uses_default_media_type_when_none_specified(self, asgi, util):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as test_simple_json_query_string, would remove

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

@MannXo MannXo requested a review from CaselIT January 25, 2026 09:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add new method req.get_query_string_as_media()

3 participants