Skip to content

API Reference

Support for the HTTP QUERY method (RFC 10008) in Django REST framework.

This is a stop-gap until DRF supports QUERY itself (encode/django-rest-framework#10091). It mirrors the names used there, so moving to DRF's own support means removing this package rather than rewriting views.

There are two ways to use it:

  • Opt-in classes with the same names as DRF's, for example drf_query_method.filters.SearchFilter, imported in place of DRF's.
  • :func:patch, which applies the same behaviour to DRF itself, and to django-filter when it is installed.

add_query_method()

Make Django before 6.2 handle QUERY as Django 6.2 does.

Django 6.2 supports QUERY natively, so this does nothing there. On earlier versions it adds query to View.http_method_names, so any view, including DRF's, can dispatch QUERY requests to a query() handler, and it treats QUERY as a safe method in CsrfViewMiddleware, so QUERY requests need no CSRF token. See :func:skip_csrf_for_query.

Source code in src/drf_query_method/methods.py
def add_query_method() -> None:
    """Make Django before 6.2 handle ``QUERY`` as Django 6.2 does.

    Django 6.2 supports ``QUERY`` natively, so this does nothing there. On
    earlier versions it adds ``query`` to ``View.http_method_names``, so any
    view, including DRF's, can dispatch ``QUERY`` requests to a ``query()``
    handler, and it treats ``QUERY`` as a safe method in
    ``CsrfViewMiddleware``, so ``QUERY`` requests need no CSRF token. See
    :func:`skip_csrf_for_query`.
    """
    if "query" in View.http_method_names:
        return
    View.http_method_names = [*View.http_method_names, "query"]
    CsrfViewMiddleware.process_view = skip_csrf_for_query(CsrfViewMiddleware.process_view)

filter_params(request)

The parameters filter backends should read for request.

Uses request.filter_params when DRF provides it, natively or through :func:drf_query_method.patch, and computes it otherwise. Custom filter backends should read their parameters through this rather than request.query_params, so that they support QUERY requests.

Source code in src/drf_query_method/params.py
def filter_params(request) -> QueryDict:
    """The parameters filter backends should read for ``request``.

    Uses ``request.filter_params`` when DRF provides it, natively or through
    :func:`drf_query_method.patch`, and computes it otherwise. Custom filter
    backends should read their parameters through this rather than
    ``request.query_params``, so that they support ``QUERY`` requests.
    """
    if hasattr(type(request), "filter_params"):
        return request.filter_params
    return merge_filter_params(request)

patch()

Add QUERY support to Django, DRF, django-filter and django-rest-framework-datatables in place.

Safe to call more than once. On Django versions before 6.2 this also calls :func:~drf_query_method.add_query_method, which :func:unpatch does not undo.

Source code in src/drf_query_method/patching.py
def patch() -> None:
    """Add QUERY support to Django, DRF, django-filter and django-rest-framework-datatables in place.

    Safe to call more than once. On Django versions before 6.2 this also
    calls :func:`~drf_query_method.add_query_method`, which :func:`unpatch`
    does not undo.
    """
    if _applied:
        return

    from rest_framework.request import Request

    add_query_method()
    if not hasattr(Request, "filter_params"):
        _patch_drf()
    if importlib.util.find_spec("django_filters") is not None:
        _patch_django_filter()
    if importlib.util.find_spec("rest_framework_datatables") is not None:
        _patch_drf_datatables()

unpatch()

Undo :func:patch, mainly for tests.

Source code in src/drf_query_method/patching.py
def unpatch() -> None:
    """Undo :func:`patch`, mainly for tests."""
    while _applied:
        owner, name, original = _applied.pop()
        if original is _MISSING:
            delattr(owner, name)
        else:
            setattr(owner, name, original)

django_filters

django-filter's DRF backend, reading QUERY request bodies.

Importing this module requires django-filter.

as_filter_data(params)

Stringify a QUERY body's scalar values, as a query string would deliver them.

django-filter's widgets expect strings. JSON numbers and booleans become "5" and "True", which its fields parse as usual, rather than reaching widgets that call .split(",") on them.

Source code in src/drf_query_method/django_filters.py
def as_filter_data(params):
    """Stringify a QUERY body's scalar values, as a query string would deliver them.

    django-filter's widgets expect strings. JSON numbers and booleans become
    ``"5"`` and ``"True"``, which its fields parse as usual, rather than
    reaching widgets that call ``.split(",")`` on them.
    """
    data = QueryDict(mutable=True)
    for key, values in params.lists():
        data.setlist(key, [value if value is None or isinstance(value, str) else str(value) for value in values])
    return data

filters

DRF's SearchFilter and OrderingFilter, reading QUERY request bodies.

get_ordering is adapted from Django REST framework (Copyright Encode OSS Ltd, BSD 3-Clause; see LICENSE).

get_ordering(self, request, queryset, view)

Ordering comes from a comma delimited ?ordering=..., or a QUERY request's body.

Source code in src/drf_query_method/filters.py
def get_ordering(self, request, queryset, view):
    """Ordering comes from a comma delimited ``?ordering=...``, or a QUERY request's body."""
    params = filter_params(request).get(self.ordering_param)
    if params and isinstance(params, str):
        fields = [param.strip() for param in params.split(",")]
        ordering = self.remove_invalid_fields(queryset, fields, view, request)
        if ordering:
            return ordering

    return self.get_default_ordering(view)

get_search_terms(self, request)

Search terms come from ?search=..., or a QUERY request's body, and may be whitespace delimited.

Source code in src/drf_query_method/filters.py
def get_search_terms(self, request):
    """Search terms come from ``?search=...``, or a QUERY request's body, and may be whitespace delimited."""
    value = filter_params(request).get(self.search_param, "")
    field = CharField(trim_whitespace=False, allow_blank=True)
    cleaned_value = field.run_validation(value)
    return filters.search_smart_split(cleaned_value)

methods

Make Django accept the QUERY method on versions before 6.2.

add_query_method()

Make Django before 6.2 handle QUERY as Django 6.2 does.

Django 6.2 supports QUERY natively, so this does nothing there. On earlier versions it adds query to View.http_method_names, so any view, including DRF's, can dispatch QUERY requests to a query() handler, and it treats QUERY as a safe method in CsrfViewMiddleware, so QUERY requests need no CSRF token. See :func:skip_csrf_for_query.

Source code in src/drf_query_method/methods.py
def add_query_method() -> None:
    """Make Django before 6.2 handle ``QUERY`` as Django 6.2 does.

    Django 6.2 supports ``QUERY`` natively, so this does nothing there. On
    earlier versions it adds ``query`` to ``View.http_method_names``, so any
    view, including DRF's, can dispatch ``QUERY`` requests to a ``query()``
    handler, and it treats ``QUERY`` as a safe method in
    ``CsrfViewMiddleware``, so ``QUERY`` requests need no CSRF token. See
    :func:`skip_csrf_for_query`.
    """
    if "query" in View.http_method_names:
        return
    View.http_method_names = [*View.http_method_names, "query"]
    CsrfViewMiddleware.process_view = skip_csrf_for_query(CsrfViewMiddleware.process_view)

skip_csrf_for_query(process_view)

Wrap CsrfViewMiddleware.process_view to accept QUERY like GET.

Django 6.2 adds QUERY to the methods the middleware exempts as safe under RFC 9110. Earlier versions hard-code GET, HEAD, OPTIONS and TRACE, so the wrapper sends QUERY down the same path: a request already processed, or a csrf_exempt view, is left alone, and anything else is accepted without a token. Every other method falls through to Django unchanged.

DRF's SessionAuthentication checks CSRF through CSRFCheck, a CsrfViewMiddleware subclass, so it follows the same rule.

Source code in src/drf_query_method/methods.py
def skip_csrf_for_query(process_view):
    """Wrap ``CsrfViewMiddleware.process_view`` to accept ``QUERY`` like ``GET``.

    Django 6.2 adds ``QUERY`` to the methods the middleware exempts as safe
    under RFC 9110. Earlier versions hard-code ``GET``, ``HEAD``, ``OPTIONS``
    and ``TRACE``, so the wrapper sends ``QUERY`` down the same path: a request
    already processed, or a ``csrf_exempt`` view, is left alone, and anything
    else is accepted without a token. Every other method falls through to
    Django unchanged.

    DRF's ``SessionAuthentication`` checks CSRF through ``CSRFCheck``, a
    ``CsrfViewMiddleware`` subclass, so it follows the same rule.
    """

    @functools.wraps(process_view)
    def wrapper(self, request, callback, callback_args, callback_kwargs):
        if request.method != "QUERY":
            return process_view(self, request, callback, callback_args, callback_kwargs)
        if getattr(request, "csrf_processing_done", False) or getattr(callback, "csrf_exempt", False):
            return None
        return self._accept(request)

    return wrapper

params

The parameters filter backends should read for a request.

filter_params(request)

The parameters filter backends should read for request.

Uses request.filter_params when DRF provides it, natively or through :func:drf_query_method.patch, and computes it otherwise. Custom filter backends should read their parameters through this rather than request.query_params, so that they support QUERY requests.

Source code in src/drf_query_method/params.py
def filter_params(request) -> QueryDict:
    """The parameters filter backends should read for ``request``.

    Uses ``request.filter_params`` when DRF provides it, natively or through
    :func:`drf_query_method.patch`, and computes it otherwise. Custom filter
    backends should read their parameters through this rather than
    ``request.query_params``, so that they support ``QUERY`` requests.
    """
    if hasattr(type(request), "filter_params"):
        return request.filter_params
    return merge_filter_params(request)

merge_filter_params(request)

Return request.query_params with a QUERY request's body merged over it.

For any method other than QUERY this is request.query_params itself. A QUERY request carries its query in the request body, so the parsed body is merged over the URL query parameters, and the body wins for any key present in both. List values in a JSON body become multiple values, as repeated query-string parameters do.

Raises ParseError (400) when the body is not an object of parameters, such as a JSON array. The result is cached on the request, as packages such as django-rest-framework-datatables read many parameters each.

Source code in src/drf_query_method/params.py
def merge_filter_params(request) -> QueryDict:
    """Return ``request.query_params`` with a QUERY request's body merged over it.

    For any method other than ``QUERY`` this is ``request.query_params``
    itself. A ``QUERY`` request carries its query in the request body, so the
    parsed body is merged over the URL query parameters, and the body wins
    for any key present in both. List values in a JSON body become multiple
    values, as repeated query-string parameters do.

    Raises ``ParseError`` (400) when the body is not an object of parameters,
    such as a JSON array. The result is cached on the request, as packages
    such as django-rest-framework-datatables read many parameters each.
    """
    if request.method != "QUERY":
        return request.query_params

    cached = request.__dict__.get(_CACHE_ATTRIBUTE)
    if cached is not None:
        return cached

    body = request.data
    if not isinstance(body, Mapping):
        raise ParseError(f"QUERY request body must be an object of parameters, not {type(body).__name__}.")

    if not isinstance(body, MultiValueDict):
        body = MultiValueDict({key: value if isinstance(value, list) else [value] for key, value in body.items()})

    params = request.query_params.copy()
    for key, values in body.lists():
        params.setlist(key, values)
    request.__dict__[_CACHE_ATTRIBUTE] = params
    return params

patching

Apply QUERY support to DRF itself, and to django-filter when installed.

:func:patch makes DRF behave as if encode/django-rest-framework#10091 were merged, so existing views, routers and settings pick up QUERY without changing imports. When django-filter or django-rest-framework-datatables is installed, they read QUERY request bodies too. Call it once at startup, for example from an AppConfig.ready(). It does nothing to DRF once DRF supports QUERY itself.

patch()

Add QUERY support to Django, DRF, django-filter and django-rest-framework-datatables in place.

Safe to call more than once. On Django versions before 6.2 this also calls :func:~drf_query_method.add_query_method, which :func:unpatch does not undo.

Source code in src/drf_query_method/patching.py
def patch() -> None:
    """Add QUERY support to Django, DRF, django-filter and django-rest-framework-datatables in place.

    Safe to call more than once. On Django versions before 6.2 this also
    calls :func:`~drf_query_method.add_query_method`, which :func:`unpatch`
    does not undo.
    """
    if _applied:
        return

    from rest_framework.request import Request

    add_query_method()
    if not hasattr(Request, "filter_params"):
        _patch_drf()
    if importlib.util.find_spec("django_filters") is not None:
        _patch_django_filter()
    if importlib.util.find_spec("rest_framework_datatables") is not None:
        _patch_drf_datatables()

unpatch()

Undo :func:patch, mainly for tests.

Source code in src/drf_query_method/patching.py
def unpatch() -> None:
    """Undo :func:`patch`, mainly for tests."""
    while _applied:
        owner, name, original = _applied.pop()
        if original is _MISSING:
            delattr(owner, name)
        else:
            setattr(owner, name, original)

permissions

DRF permission classes that treat QUERY as a safe method.

has_object_permission is adapted from Django REST framework (Copyright Encode OSS Ltd, BSD 3-Clause; see LICENSE).

routers

DRF routers that route QUERY to a viewset's query() on the list route.

get_routes_keeping_query_actions(router, viewset, get_routes)

Call get_routes, leaving QUERY unrouted when the viewset has an @action named query.

That action keeps its own route, and DRF would otherwise reject it for clashing with the built-in query route.

Source code in src/drf_query_method/routers.py
def get_routes_keeping_query_actions(router, viewset, get_routes):
    """Call ``get_routes``, leaving QUERY unrouted when the viewset has an ``@action`` named ``query``.

    That action keeps its own route, and DRF would otherwise reject it for
    clashing with the built-in ``query`` route.
    """
    if not has_query_action(viewset):
        return get_routes(router, viewset)

    router_without_query = copy.copy(router)
    router_without_query.routes = without_query_route(router.routes)
    return get_routes(router_without_query, viewset)

has_query_action(viewset)

Whether the viewset already has an @action named query.

Source code in src/drf_query_method/routers.py
def has_query_action(viewset) -> bool:
    """Whether the viewset already has an ``@action`` named ``query``."""
    return any(action.__name__ == "query" for action in viewset.get_extra_actions())

with_query_route(routes)

Map QUERY to query on the list route of routes.

Source code in src/drf_query_method/routers.py
def with_query_route(routes):
    """Map QUERY to ``query`` on the list route of ``routes``."""
    return [
        route._replace(mapping={**route.mapping, "query": "query"}) if _is_list_route(route) else route
        for route in routes
    ]

without_query_route(routes)

Remove the QUERY mapping from every route in routes.

Source code in src/drf_query_method/routers.py
def without_query_route(routes):
    """Remove the QUERY mapping from every route in ``routes``."""
    return [
        route._replace(mapping={method: action for method, action in route.mapping.items() if action != "query"})
        if isinstance(route, routers.Route)
        else route
        for route in routes
    ]

test

DRF's test request factory and client, with a .query() method.

client_query(self, path, data=None, format=None, content_type=None, follow=False, **extra)

Send a QUERY request, encoding data as .post() does.

Source code in src/drf_query_method/test.py
def client_query(self, path, data=None, format=None, content_type=None, follow=False, **extra):
    """Send a QUERY request, encoding ``data`` as ``.post()`` does."""
    response = factory_query(self, path, data=data, format=format, content_type=content_type, **extra)
    if follow:
        response = self._handle_redirects(response, data=data, format=format, content_type=content_type, **extra)
    return response

factory_query(self, path, data=None, format=None, content_type=None, **extra)

Build a QUERY request, encoding data as .post() does.

Source code in src/drf_query_method/test.py
6
7
8
9
def factory_query(self, path, data=None, format=None, content_type=None, **extra):
    """Build a QUERY request, encoding ``data`` as ``.post()`` does."""
    data, content_type = self._encode_data(data, format, content_type)
    return self.generic("QUERY", path, data, content_type, **extra)

viewsets

DRF viewsets that only answer QUERY on routes that map it.

drop_unmapped_query(view)

Stop a viewset answering QUERY on a route whose actions don't map it.

A viewset's query() method has the same name as the QUERY handler, so without this every route of a viewset that defines query() would answer QUERY, including detail routes.

Source code in src/drf_query_method/viewsets.py
def drop_unmapped_query(view) -> None:
    """Stop a viewset answering QUERY on a route whose actions don't map it.

    A viewset's ``query()`` method has the same name as the QUERY handler, so
    without this every route of a viewset that defines ``query()`` would
    answer QUERY, including detail routes.
    """
    if "query" not in (getattr(view, "action_map", None) or {}):
        view.http_method_names = [method for method in view.http_method_names if method != "query"]