From 7478e8d6e041bf85556e76883d1d51d87b3a2120 Mon Sep 17 00:00:00 2001 From: lotte-amsterdam Date: Thu, 30 Apr 2026 08:55:51 +0200 Subject: [PATCH 1/6] added handling for header query params with prefix --- src/dso_api/dynamic_api/filters/parser.py | 52 ++++++++- src/tests/test_dynamic_api/test_filters.py | 122 ++++++++++++++++++++- 2 files changed, 169 insertions(+), 5 deletions(-) diff --git a/src/dso_api/dynamic_api/filters/parser.py b/src/dso_api/dynamic_api/filters/parser.py index c09b5fcd6..e540199c3 100644 --- a/src/dso_api/dynamic_api/filters/parser.py +++ b/src/dso_api/dynamic_api/filters/parser.py @@ -4,6 +4,7 @@ """ import operator +import re from datetime import datetime from functools import reduce from typing import Any, NamedTuple @@ -12,6 +13,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models from django.db.models import Q +from django.http import QueryDict from django.utils.datastructures import MultiValueDict from gisserver.geometries import CRS from rest_framework.exceptions import PermissionDenied, ValidationError @@ -24,6 +26,8 @@ from .values import str2bool, str2geo, str2isodate, str2number, str2time +SNAKE_CASE_RE = re.compile(r"^[a-z][a-z0-9_]*$") + # Lookups that have specific value types: LOOKUP_PARSERS = { @@ -178,30 +182,70 @@ class QueryFilterEngine: @classmethod def from_request(cls, request) -> QueryFilterEngine: """Construct the parser from the request data.""" + qp_headers = QueryDict(mutable=True) + + for key, value in request.headers.items(): + if key.lower().startswith("qp-"): + raw_key = key[3:].lower() + normalized_key = raw_key.replace("-", "_") + + if not SNAKE_CASE_RE.match(normalized_key): + # headers must be passed in snake case with a qp- prefix + raise ValidationError( + f"Invalid qp header '{normalized_key}'. Must be snake_case." + ) + + qp_headers.setlist(normalized_key, [value]) + return cls( user_scopes=request.user_scopes, query=request.GET, + # neemt aan dat headers al een aparte DSO-filters dict heeft + # dus is prefix dan nog nodig ook? + # header_filters=request.headers #.get("DSO-Filters"), + qp_headers=qp_headers, input_crs=getattr(request, "accept_crs", None), request_date=get_request_date(request), ) def __init__( - self, user_scopes: UserScopes, query: MultiValueDict, input_crs: CRS, request_date=None + self, + user_scopes: UserScopes, + query: MultiValueDict, + input_crs: CRS, + request_date=None, + qp_headers=None, ): """Initialize the filtering engine using the context provided by the request.""" self.user_scopes = user_scopes self.query = query self.input_crs = input_crs - self.filter_inputs = self._parse_filters(query) + self.filter_inputs = self._parse_filters(query, qp_headers) self.request_date = request_date + self.qp_headers = qp_headers def __bool__(self): return bool(self.filter_inputs) - def _parse_filters(self, query: MultiValueDict) -> list[FilterInput]: - """Translate raw HTTP GET parameters into a Python structure""" + def _parse_filters( + self, + query: MultiValueDict, + qp_headers: MultiValueDict, + ) -> list[FilterInput]: + """Translate raw HTTP GET parameters and custom + header query parameters into a Python structure""" filters = [] + # if qp_headers: + # query = query.copy() + # query.update(qp_headers) + if qp_headers: + query = query.copy() + + # qp_headers overwrite query + for key, values in qp_headers.lists(): + query.setlist(key, values) + for key in sorted(query): if key in self.NON_FILTER_PARAMS: continue diff --git a/src/tests/test_dynamic_api/test_filters.py b/src/tests/test_dynamic_api/test_filters.py index f386625e8..a8dc97716 100644 --- a/src/tests/test_dynamic_api/test_filters.py +++ b/src/tests/test_dynamic_api/test_filters.py @@ -12,6 +12,7 @@ from dso_api.dynamic_api.filters import parser from dso_api.dynamic_api.filters.lookups import _sql_wildcards +from dso_api.dynamic_api.filters.parser import QueryFilterEngine from dso_api.dynamic_api.filters.values import _parse_point, _validate_correct_x_y, str2geo from rest_framework_dso.crs import RD_NEW @@ -36,14 +37,18 @@ def test_sql_wildcards(): assert _sql_wildcards("f?_oob%ar*") == r"f_\_oob\%ar%" -def create_filter_engine(query_string: str, request_scopes=()) -> parser.QueryFilterEngine: +def create_filter_engine( + query_string: str, request_scopes=(), qp_headers=None +) -> parser.QueryFilterEngine: """Simulate creation of a filter engine, based on request data.""" get_params = QueryDict(query_string) + qp_headers = QueryDict(qp_headers) if qp_headers else None return parser.QueryFilterEngine( user_scopes=UserScopes(get_params, request_scopes), query=get_params, input_crs=RD_NEW, request_date=now(), + qp_headers=qp_headers, ) @@ -107,6 +112,121 @@ def test_filter_logic(self, movies_model, movie1, movie2, query, expect): qs = engine.filter_queryset(movies_model.objects.all()) assert {obj.name for obj in qs} == expect, str(qs.query) + def test_qp_headers(self, movies_model, movie1, movie2): + """Prove that filtering with qp headers work.""" + engine = create_filter_engine("dateAdded[lt]=2020-3-1T23:00:00", qp_headers="name=movie1") + qs = engine.filter_queryset(movies_model.objects.all()) + assert {obj.name for obj in qs} == {"movie1"} + + def test_qp_headers_overwrite_query(self, movies_model, movie1, movie2): + """Prove that filtering with qp headers work.""" + engine = create_filter_engine( + "dateAdded[lt]=2020-3-1T23:00:00&name=movie2", qp_headers="name=movie1" + ) + qs = engine.filter_queryset(movies_model.objects.all()) + assert {obj.name for obj in qs} == {"movie1"} + + def test_multiple_qp_headers(self, movies_model, movie1, movie2): + """Prove that filtering with qp headers work.""" + engine = create_filter_engine( + "name=movie1", qp_headers="dateAdded[lt]=2020-3-1T23:00:00&name=movie2" + ) + qs = engine.filter_queryset(movies_model.objects.all()) + assert {obj.name for obj in qs} == {"movie2"} + + # test functionaliteit gt, lt en equals + # django headers expect uppercase and underscores instead of dashes + # test dat headers in snake case met qp- prefix moeten worden gepassed + + def test_qp_headers_extraction(self, api_rf): + request = api_rf.get( + "/test", + HTTP_QP_STATUS="active", + HTTP_QP_STATUS_ROLE="admin", + HTTP_OTHER_HEADER="ignore-me", + ) + + request.accept_crs = None + request.user_scopes = UserScopes( + query_params=request.GET, + request_scopes=["BAG/R"], + ) + request.request_date = now() + # request.headers converts it back to http style + # so Qp-Status-Role for example + engine = QueryFilterEngine.from_request(request) + + assert dict(engine.qp_headers.lists()) == { + "status": ["active"], + "status_role": ["admin"], + } + + def test_qp_headers_case_insensitive(self, api_rf): + request = api_rf.get( + "/test", + HTTP_QP_STATUS="active", + HTTP_qp_type="premium", + ) + + request.accept_crs = None + request.user_scopes = UserScopes( + query_params=request.GET, + request_scopes=["BAG/R"], + ) + request.request_date = now() + engine = QueryFilterEngine.from_request(request) + + assert dict(engine.qp_headers.lists()) == { + "status": ["active"], + "type": ["premium"], + } + + def test_non_qp_headers_ignored(self, api_rf): + request = api_rf.get( + "/test", + HTTP_AUTHORIZATION="Bearer token", + HTTP_CONTENT_TYPE="application/json", + ) + + request.accept_crs = None + request.user_scopes = UserScopes( + query_params=request.GET, + request_scopes=["BAG/R"], + ) + request.request_date = now() + engine = QueryFilterEngine.from_request(request) + + assert dict(engine.qp_headers.lists()) == {} + + @pytest.mark.parametrize( + "headers, expected", + [ + ( + {"HTTP_QP_STATUS": "active"}, + {"status": ["active"]}, + ), + ( + {"HTTP_QP_STATUS": "active", "HTTP_QP_ROLE": "admin"}, + {"status": ["active"], "role": ["admin"]}, + ), + ( + {"HTTP_OTHER": "x"}, + {}, + ), + ], + ) + def test_qp_headers_parametrized(self, api_rf, headers, expected): + request = api_rf.get("/test", **headers) + request.accept_crs = None + request.user_scopes = UserScopes( + query_params=request.GET, + request_scopes=["BAG/R"], + ) + request.request_date = now() + engine = QueryFilterEngine.from_request(request) + + assert dict(engine.qp_headers.lists()) == expected + @pytest.mark.parametrize( "query,expect", [ From 4e9bd48bcf3cbca196d8ce3a56b0cdde414bc18e Mon Sep 17 00:00:00 2001 From: lotte-amsterdam Date: Mon, 4 May 2026 19:15:54 +0200 Subject: [PATCH 2/6] changed prefix and added logic for operators + tests --- src/dso_api/dynamic_api/filters/parser.py | 62 +++++++------ src/tests/test_dynamic_api/test_filters.py | 103 +++++++++------------ 2 files changed, 76 insertions(+), 89 deletions(-) diff --git a/src/dso_api/dynamic_api/filters/parser.py b/src/dso_api/dynamic_api/filters/parser.py index e540199c3..50e8452fb 100644 --- a/src/dso_api/dynamic_api/filters/parser.py +++ b/src/dso_api/dynamic_api/filters/parser.py @@ -4,7 +4,6 @@ """ import operator -import re from datetime import datetime from functools import reduce from typing import Any, NamedTuple @@ -26,8 +25,6 @@ from .values import str2bool, str2geo, str2isodate, str2number, str2time -SNAKE_CASE_RE = re.compile(r"^[a-z][a-z0-9_]*$") - # Lookups that have specific value types: LOOKUP_PARSERS = { @@ -178,32 +175,44 @@ class QueryFilterEngine: "_csv_header", "_csv_separator", } + PREFIX = "Dso-" @classmethod def from_request(cls, request) -> QueryFilterEngine: """Construct the parser from the request data.""" - qp_headers = QueryDict(mutable=True) + dso_headers = QueryDict(mutable=True) for key, value in request.headers.items(): - if key.lower().startswith("qp-"): - raw_key = key[3:].lower() - normalized_key = raw_key.replace("-", "_") - - if not SNAKE_CASE_RE.match(normalized_key): - # headers must be passed in snake case with a qp- prefix - raise ValidationError( - f"Invalid qp header '{normalized_key}'. Must be snake_case." - ) + if not key.startswith(cls.PREFIX): + continue + + # custom query headers are passed with a DSO- prefix + raw_key = key[len(cls.PREFIX) :] + + # default operator will be "eq" + operator = "eq" + + if raw_key.lower().endswith("-gt"): + operator = "gt" + raw_key = raw_key[:-3] + elif raw_key.lower().endswith("-lt"): + operator = "lt" + raw_key = raw_key[:-3] + + # convert query param to camelCase + query_parts = raw_key.split("-") + query_param = query_parts[0].lower() + "".join( + part.capitalize() for part in query_parts[1:] + ) + + new_key = f"{query_param}[{operator}]" - qp_headers.setlist(normalized_key, [value]) + dso_headers.setlist(new_key, [value]) return cls( user_scopes=request.user_scopes, query=request.GET, - # neemt aan dat headers al een aparte DSO-filters dict heeft - # dus is prefix dan nog nodig ook? - # header_filters=request.headers #.get("DSO-Filters"), - qp_headers=qp_headers, + dso_headers=dso_headers, input_crs=getattr(request, "accept_crs", None), request_date=get_request_date(request), ) @@ -214,15 +223,15 @@ def __init__( query: MultiValueDict, input_crs: CRS, request_date=None, - qp_headers=None, + dso_headers=None, ): """Initialize the filtering engine using the context provided by the request.""" self.user_scopes = user_scopes self.query = query self.input_crs = input_crs - self.filter_inputs = self._parse_filters(query, qp_headers) + self.filter_inputs = self._parse_filters(query, dso_headers) self.request_date = request_date - self.qp_headers = qp_headers + self.dso_headers = dso_headers def __bool__(self): return bool(self.filter_inputs) @@ -230,20 +239,17 @@ def __bool__(self): def _parse_filters( self, query: MultiValueDict, - qp_headers: MultiValueDict, + dso_headers: MultiValueDict, ) -> list[FilterInput]: """Translate raw HTTP GET parameters and custom header query parameters into a Python structure""" filters = [] - # if qp_headers: - # query = query.copy() - # query.update(qp_headers) - if qp_headers: + if dso_headers: query = query.copy() - # qp_headers overwrite query - for key, values in qp_headers.lists(): + for key, values in dso_headers.lists(): + # dso_headers are leading query.setlist(key, values) for key in sorted(query): diff --git a/src/tests/test_dynamic_api/test_filters.py b/src/tests/test_dynamic_api/test_filters.py index a8dc97716..3ea57aeae 100644 --- a/src/tests/test_dynamic_api/test_filters.py +++ b/src/tests/test_dynamic_api/test_filters.py @@ -38,17 +38,17 @@ def test_sql_wildcards(): def create_filter_engine( - query_string: str, request_scopes=(), qp_headers=None + query_string: str, request_scopes=(), dso_headers=None ) -> parser.QueryFilterEngine: """Simulate creation of a filter engine, based on request data.""" get_params = QueryDict(query_string) - qp_headers = QueryDict(qp_headers) if qp_headers else None + dso_headers = QueryDict(dso_headers) if dso_headers else None return parser.QueryFilterEngine( user_scopes=UserScopes(get_params, request_scopes), query=get_params, input_crs=RD_NEW, request_date=now(), - qp_headers=qp_headers, + dso_headers=dso_headers, ) @@ -112,38 +112,38 @@ def test_filter_logic(self, movies_model, movie1, movie2, query, expect): qs = engine.filter_queryset(movies_model.objects.all()) assert {obj.name for obj in qs} == expect, str(qs.query) - def test_qp_headers(self, movies_model, movie1, movie2): + def test_dso_headers(self, movies_model, movie1, movie2): """Prove that filtering with qp headers work.""" - engine = create_filter_engine("dateAdded[lt]=2020-3-1T23:00:00", qp_headers="name=movie1") + engine = create_filter_engine("dateAdded[lt]=2020-3-1T23:00:00", dso_headers="name=movie1") qs = engine.filter_queryset(movies_model.objects.all()) assert {obj.name for obj in qs} == {"movie1"} - def test_qp_headers_overwrite_query(self, movies_model, movie1, movie2): + def test_dso_headers_overwrite_query(self, movies_model, movie1, movie2): """Prove that filtering with qp headers work.""" engine = create_filter_engine( - "dateAdded[lt]=2020-3-1T23:00:00&name=movie2", qp_headers="name=movie1" + "dateAdded[lt]=2020-3-1T23:00:00&name=movie2", dso_headers="name=movie1" ) qs = engine.filter_queryset(movies_model.objects.all()) assert {obj.name for obj in qs} == {"movie1"} - def test_multiple_qp_headers(self, movies_model, movie1, movie2): + def test_multiple_dso_headers(self, movies_model, movie1, movie2): """Prove that filtering with qp headers work.""" engine = create_filter_engine( - "name=movie1", qp_headers="dateAdded[lt]=2020-3-1T23:00:00&name=movie2" + "name=movie1", dso_headers="dateAdded[lt]=2020-3-1T23:00:00&name=movie2" ) qs = engine.filter_queryset(movies_model.objects.all()) assert {obj.name for obj in qs} == {"movie2"} - # test functionaliteit gt, lt en equals - # django headers expect uppercase and underscores instead of dashes - # test dat headers in snake case met qp- prefix moeten worden gepassed - - def test_qp_headers_extraction(self, api_rf): + def test_dso_headers_extraction(self, api_rf): request = api_rf.get( "/test", - HTTP_QP_STATUS="active", - HTTP_QP_STATUS_ROLE="admin", - HTTP_OTHER_HEADER="ignore-me", + headers={ + "DSO-aantal-bouwlagen": 5, + "DSO-status": "active", + "DSO-aantal-bouwlagen_gt": 2, + "DSO-aantal-bouwlagen_lt": 10, + "other-header": "ignore-me", + }, ) request.accept_crs = None @@ -152,20 +152,25 @@ def test_qp_headers_extraction(self, api_rf): request_scopes=["BAG/R"], ) request.request_date = now() - # request.headers converts it back to http style - # so Qp-Status-Role for example engine = QueryFilterEngine.from_request(request) - assert dict(engine.qp_headers.lists()) == { - "status": ["active"], - "status_role": ["admin"], + assert dict(engine.dso_headers.lists()) == { + "aantalBouwlagen[eq]": [5], + "status[eq]": ["active"], + "aantalBouwlagen[gt]": [2], + "aantalBouwlagen[lt]": [10], } - def test_qp_headers_case_insensitive(self, api_rf): + def test_dso_headers_case_insensitive(self, api_rf): request = api_rf.get( "/test", - HTTP_QP_STATUS="active", - HTTP_qp_type="premium", + headers={ + "dso-aantal-bouwlagen": 5, + "DSO-status": "active", + "DSO-aantal-BOUWLAGEN_gt": 2, + "DSO-Aantal-bouwlagen_lt": 10, + "other-header": "ignore-me", + }, ) request.accept_crs = None @@ -176,47 +181,23 @@ def test_qp_headers_case_insensitive(self, api_rf): request.request_date = now() engine = QueryFilterEngine.from_request(request) - assert dict(engine.qp_headers.lists()) == { - "status": ["active"], - "type": ["premium"], + assert dict(engine.dso_headers.lists()) == { + "aantalBouwlagen[eq]": [5], + "status[eq]": ["active"], + "aantalBouwlagen[gt]": [2], + "aantalBouwlagen[lt]": [10], } - def test_non_qp_headers_ignored(self, api_rf): + def test_non_dso_headers_ignored(self, api_rf): request = api_rf.get( "/test", - HTTP_AUTHORIZATION="Bearer token", - HTTP_CONTENT_TYPE="application/json", - ) - - request.accept_crs = None - request.user_scopes = UserScopes( - query_params=request.GET, - request_scopes=["BAG/R"], + headers={ + "aantal-bouwlagen": 5, + "status": "active", + "other-header": "ignore-me", + }, ) - request.request_date = now() - engine = QueryFilterEngine.from_request(request) - assert dict(engine.qp_headers.lists()) == {} - - @pytest.mark.parametrize( - "headers, expected", - [ - ( - {"HTTP_QP_STATUS": "active"}, - {"status": ["active"]}, - ), - ( - {"HTTP_QP_STATUS": "active", "HTTP_QP_ROLE": "admin"}, - {"status": ["active"], "role": ["admin"]}, - ), - ( - {"HTTP_OTHER": "x"}, - {}, - ), - ], - ) - def test_qp_headers_parametrized(self, api_rf, headers, expected): - request = api_rf.get("/test", **headers) request.accept_crs = None request.user_scopes = UserScopes( query_params=request.GET, @@ -225,7 +206,7 @@ def test_qp_headers_parametrized(self, api_rf, headers, expected): request.request_date = now() engine = QueryFilterEngine.from_request(request) - assert dict(engine.qp_headers.lists()) == expected + assert dict(engine.dso_headers.lists()) == {} @pytest.mark.parametrize( "query,expect", From f9fdf58ad92734f82f905c7eaada429632107a65 Mon Sep 17 00:00:00 2001 From: lotte-amsterdam Date: Wed, 6 May 2026 14:53:38 +0200 Subject: [PATCH 3/6] changed after review --- src/dso_api/dynamic_api/filters/parser.py | 27 ++-- src/tests/test_dynamic_api/test_filters.py | 138 ++++++++++----------- 2 files changed, 75 insertions(+), 90 deletions(-) diff --git a/src/dso_api/dynamic_api/filters/parser.py b/src/dso_api/dynamic_api/filters/parser.py index 50e8452fb..5aa43964b 100644 --- a/src/dso_api/dynamic_api/filters/parser.py +++ b/src/dso_api/dynamic_api/filters/parser.py @@ -12,11 +12,11 @@ from django.core.exceptions import ValidationError as DjangoValidationError from django.db import models from django.db.models import Q -from django.http import QueryDict from django.utils.datastructures import MultiValueDict from gisserver.geometries import CRS from rest_framework.exceptions import PermissionDenied, ValidationError from schematools.exceptions import SchemaObjectNotFound +from schematools.naming import toCamelCase from schematools.permissions import UserScopes from schematools.types import AdditionalRelationSchema, DatasetFieldSchema, DatasetTableSchema @@ -175,35 +175,33 @@ class QueryFilterEngine: "_csv_header", "_csv_separator", } - PREFIX = "Dso-" + HEADER_PARAMS_PREFIX = "Dso-" @classmethod def from_request(cls, request) -> QueryFilterEngine: """Construct the parser from the request data.""" - dso_headers = QueryDict(mutable=True) + dso_headers = MultiValueDict() for key, value in request.headers.items(): - if not key.startswith(cls.PREFIX): + if not key.startswith(cls.HEADER_PARAMS_PREFIX): continue # custom query headers are passed with a DSO- prefix - raw_key = key[len(cls.PREFIX) :] + raw_key = key[len(cls.HEADER_PARAMS_PREFIX) :] # default operator will be "eq" operator = "eq" - if raw_key.lower().endswith("-gt"): + if raw_key.lower().endswith(".gt"): operator = "gt" raw_key = raw_key[:-3] - elif raw_key.lower().endswith("-lt"): + elif raw_key.lower().endswith(".lt"): operator = "lt" raw_key = raw_key[:-3] # convert query param to camelCase - query_parts = raw_key.split("-") - query_param = query_parts[0].lower() + "".join( - part.capitalize() for part in query_parts[1:] - ) + query_part = raw_key.replace("-", "") + query_param = toCamelCase(query_part) new_key = f"{query_param}[{operator}]" @@ -223,7 +221,7 @@ def __init__( query: MultiValueDict, input_crs: CRS, request_date=None, - dso_headers=None, + dso_headers: MultiValueDict | None = None, ): """Initialize the filtering engine using the context provided by the request.""" self.user_scopes = user_scopes @@ -239,15 +237,14 @@ def __bool__(self): def _parse_filters( self, query: MultiValueDict, - dso_headers: MultiValueDict, + dso_headers: MultiValueDict | None = None, ) -> list[FilterInput]: """Translate raw HTTP GET parameters and custom header query parameters into a Python structure""" filters = [] + query = MultiValueDict(query.lists()) if dso_headers: - query = query.copy() - for key, values in dso_headers.lists(): # dso_headers are leading query.setlist(key, values) diff --git a/src/tests/test_dynamic_api/test_filters.py b/src/tests/test_dynamic_api/test_filters.py index 3ea57aeae..0daf6a977 100644 --- a/src/tests/test_dynamic_api/test_filters.py +++ b/src/tests/test_dynamic_api/test_filters.py @@ -4,6 +4,7 @@ import pytest from django.apps import apps from django.http import QueryDict +from django.utils.datastructures import MultiValueDict from django.utils.timezone import now from gisserver.geometries import CRS from rest_framework.exceptions import ValidationError @@ -38,11 +39,11 @@ def test_sql_wildcards(): def create_filter_engine( - query_string: str, request_scopes=(), dso_headers=None + query_string: str, request_scopes=(), dso_headers: MultiValueDict | None = None ) -> parser.QueryFilterEngine: """Simulate creation of a filter engine, based on request data.""" get_params = QueryDict(query_string) - dso_headers = QueryDict(dso_headers) if dso_headers else None + dso_headers = MultiValueDict(dso_headers) if dso_headers else None return parser.QueryFilterEngine( user_scopes=UserScopes(get_params, request_scopes), query=get_params, @@ -113,65 +114,76 @@ def test_filter_logic(self, movies_model, movie1, movie2, query, expect): assert {obj.name for obj in qs} == expect, str(qs.query) def test_dso_headers(self, movies_model, movie1, movie2): - """Prove that filtering with qp headers work.""" - engine = create_filter_engine("dateAdded[lt]=2020-3-1T23:00:00", dso_headers="name=movie1") - qs = engine.filter_queryset(movies_model.objects.all()) - assert {obj.name for obj in qs} == {"movie1"} - - def test_dso_headers_overwrite_query(self, movies_model, movie1, movie2): """Prove that filtering with qp headers work.""" engine = create_filter_engine( - "dateAdded[lt]=2020-3-1T23:00:00&name=movie2", dso_headers="name=movie1" + "dateAdded[lt]=2020-3-1T23:00:00", + dso_headers=MultiValueDict( + { + "name": ["movie1"], + } + ), ) qs = engine.filter_queryset(movies_model.objects.all()) assert {obj.name for obj in qs} == {"movie1"} - def test_multiple_dso_headers(self, movies_model, movie1, movie2): + def test_dso_headers_overwrite_query(self, movies_model, movie1, movie2): """Prove that filtering with qp headers work.""" engine = create_filter_engine( - "name=movie1", dso_headers="dateAdded[lt]=2020-3-1T23:00:00&name=movie2" + "dateAdded[lt]=2020-3-1T23:00:00&name=movie2", + dso_headers=MultiValueDict( + { + "name": ["movie1"], + } + ), ) qs = engine.filter_queryset(movies_model.objects.all()) - assert {obj.name for obj in qs} == {"movie2"} - - def test_dso_headers_extraction(self, api_rf): - request = api_rf.get( - "/test", - headers={ - "DSO-aantal-bouwlagen": 5, - "DSO-status": "active", - "DSO-aantal-bouwlagen_gt": 2, - "DSO-aantal-bouwlagen_lt": 10, - "other-header": "ignore-me", - }, - ) - - request.accept_crs = None - request.user_scopes = UserScopes( - query_params=request.GET, - request_scopes=["BAG/R"], - ) - request.request_date = now() - engine = QueryFilterEngine.from_request(request) + assert {obj.name for obj in qs} == {"movie1"} - assert dict(engine.dso_headers.lists()) == { - "aantalBouwlagen[eq]": [5], - "status[eq]": ["active"], - "aantalBouwlagen[gt]": [2], - "aantalBouwlagen[lt]": [10], - } - - def test_dso_headers_case_insensitive(self, api_rf): - request = api_rf.get( - "/test", - headers={ - "dso-aantal-bouwlagen": 5, - "DSO-status": "active", - "DSO-aantal-BOUWLAGEN_gt": 2, - "DSO-Aantal-bouwlagen_lt": 10, - "other-header": "ignore-me", - }, - ) + @pytest.mark.parametrize( + "headers, expected", + [ + ( + { + "DSO-aantal-bouwlagen": 5, + "DSO-status": "active", + "DSO-aantal-bouwlagen.gt": 2, + "DSO-aantal-bouwlagen.lt": 10, + "other-header": "ignore-me", + }, + { + "aantalBouwlagen[eq]": [5], + "status[eq]": ["active"], + "aantalBouwlagen[gt]": [2], + "aantalBouwlagen[lt]": [10], + }, + ), + ( + { + "dso-aantal-bouwlagen": 5, + "DSO-status": "active", + "DSO-aantal-BOUWLAGEN.gt": 2, + "DSO-Aantal-bouwlagen.lt": 10, + "other-header": "ignore-me", + }, + { + "aantalBouwlagen[eq]": [5], + "status[eq]": ["active"], + "aantalBouwlagen[gt]": [2], + "aantalBouwlagen[lt]": [10], + }, + ), + ( + { + "aantal-bouwlagen": 5, + "status": "active", + "other-header": "ignore-me", + }, + {}, + ), + ], + ) + def test_dso_headers_extraction(self, api_rf, headers, expected): + request = api_rf.get("/test", headers=headers) request.accept_crs = None request.user_scopes = UserScopes( @@ -179,34 +191,10 @@ def test_dso_headers_case_insensitive(self, api_rf): request_scopes=["BAG/R"], ) request.request_date = now() - engine = QueryFilterEngine.from_request(request) - assert dict(engine.dso_headers.lists()) == { - "aantalBouwlagen[eq]": [5], - "status[eq]": ["active"], - "aantalBouwlagen[gt]": [2], - "aantalBouwlagen[lt]": [10], - } - - def test_non_dso_headers_ignored(self, api_rf): - request = api_rf.get( - "/test", - headers={ - "aantal-bouwlagen": 5, - "status": "active", - "other-header": "ignore-me", - }, - ) - - request.accept_crs = None - request.user_scopes = UserScopes( - query_params=request.GET, - request_scopes=["BAG/R"], - ) - request.request_date = now() engine = QueryFilterEngine.from_request(request) - assert dict(engine.dso_headers.lists()) == {} + assert dict(engine.dso_headers.lists()) == expected @pytest.mark.parametrize( "query,expect", From 57970bb96bd35ef5e54ac0d814e933836f8b35d1 Mon Sep 17 00:00:00 2001 From: lotte-amsterdam Date: Wed, 6 May 2026 17:07:36 +0200 Subject: [PATCH 4/6] documentatie --- .../dso_api/dynamic_api/docs/rest/filtering.md | 13 +++++++++++++ src/tests/test_dynamic_api/test_filters.py | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/templates/dso_api/dynamic_api/docs/rest/filtering.md b/src/templates/dso_api/dynamic_api/docs/rest/filtering.md index c1c2bf0d1..d4f6b9f28 100644 --- a/src/templates/dso_api/dynamic_api/docs/rest/filtering.md +++ b/src/templates/dso_api/dynamic_api/docs/rest/filtering.md @@ -32,6 +32,19 @@ kan gefilterd worden met: curl 'https://api.data.amsterdam.nl/v1/bag/verblijfsobjecten/?gebruiksdoel.code=1' ``` +## Filteren via custom headers +Er kan ook gefilterd worden op attributen via custom headers. Deze +headers moeten dan wel geprefixed zijn met "DSO-" om verwarring +met andere headers te voorkomen. Via headers zijn operatoren lt +(less than), equals (dit is de default) en gt (greater than) mogelijk. + +Deze query headers worden verwacht in een volgend soort structuur: +``` bash +DSO-aantal-bouwlagen +DSO-aantal-bouwlagen.gt +DSO-aantal-bouwlagen.lt +``` + ## Filteren in relaties De relaties, en attributen van relaties, kunnen gebruikt worden in diff --git a/src/tests/test_dynamic_api/test_filters.py b/src/tests/test_dynamic_api/test_filters.py index 0daf6a977..94e0d6e40 100644 --- a/src/tests/test_dynamic_api/test_filters.py +++ b/src/tests/test_dynamic_api/test_filters.py @@ -114,7 +114,7 @@ def test_filter_logic(self, movies_model, movie1, movie2, query, expect): assert {obj.name for obj in qs} == expect, str(qs.query) def test_dso_headers(self, movies_model, movie1, movie2): - """Prove that filtering with qp headers work.""" + """Prove that filtering with dso headers work.""" engine = create_filter_engine( "dateAdded[lt]=2020-3-1T23:00:00", dso_headers=MultiValueDict( @@ -127,7 +127,7 @@ def test_dso_headers(self, movies_model, movie1, movie2): assert {obj.name for obj in qs} == {"movie1"} def test_dso_headers_overwrite_query(self, movies_model, movie1, movie2): - """Prove that filtering with qp headers work.""" + """Prove that filtering with dso headers work.""" engine = create_filter_engine( "dateAdded[lt]=2020-3-1T23:00:00&name=movie2", dso_headers=MultiValueDict( @@ -183,6 +183,7 @@ def test_dso_headers_overwrite_query(self, movies_model, movie1, movie2): ], ) def test_dso_headers_extraction(self, api_rf, headers, expected): + """Prove that parsing of dso headers work.""" request = api_rf.get("/test", headers=headers) request.accept_crs = None From dc877771a384eae85e75ba71bbb6a3fd748dbb08 Mon Sep 17 00:00:00 2001 From: lotte-amsterdam Date: Mon, 11 May 2026 14:05:12 +0200 Subject: [PATCH 5/6] allow filtering with all operators --- src/dso_api/dynamic_api/filters/parser.py | 11 +++++------ .../dso_api/dynamic_api/docs/rest/filtering.md | 8 ++++---- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/src/dso_api/dynamic_api/filters/parser.py b/src/dso_api/dynamic_api/filters/parser.py index 5aa43964b..ff5f28216 100644 --- a/src/dso_api/dynamic_api/filters/parser.py +++ b/src/dso_api/dynamic_api/filters/parser.py @@ -192,12 +192,11 @@ def from_request(cls, request) -> QueryFilterEngine: # default operator will be "eq" operator = "eq" - if raw_key.lower().endswith(".gt"): - operator = "gt" - raw_key = raw_key[:-3] - elif raw_key.lower().endswith(".lt"): - operator = "lt" - raw_key = raw_key[:-3] + # split on operator if passed + parts = raw_key.rsplit(".", 1) + + if len(parts) == 2: + raw_key, operator = parts[0], parts[1].lower() # convert query param to camelCase query_part = raw_key.replace("-", "") diff --git a/src/templates/dso_api/dynamic_api/docs/rest/filtering.md b/src/templates/dso_api/dynamic_api/docs/rest/filtering.md index d4f6b9f28..af5a5a64e 100644 --- a/src/templates/dso_api/dynamic_api/docs/rest/filtering.md +++ b/src/templates/dso_api/dynamic_api/docs/rest/filtering.md @@ -35,14 +35,14 @@ curl 'https://api.data.amsterdam.nl/v1/bag/verblijfsobjecten/?gebruiksdoel.code= ## Filteren via custom headers Er kan ook gefilterd worden op attributen via custom headers. Deze headers moeten dan wel geprefixed zijn met "DSO-" om verwarring -met andere headers te voorkomen. Via headers zijn operatoren lt -(less than), equals (dit is de default) en gt (greater than) mogelijk. +met andere headers te voorkomen. Als er geen operatoren worden +meegegeven is de default operator gelijk aan equals. Deze query headers worden verwacht in een volgend soort structuur: ``` bash DSO-aantal-bouwlagen -DSO-aantal-bouwlagen.gt -DSO-aantal-bouwlagen.lt +DSO-aantal-bouwlagen.gte +DSO-naam.like ``` ## Filteren in relaties From bd65d494205f69f276e7918c534754f1fc4bb846 Mon Sep 17 00:00:00 2001 From: lotte-amsterdam Date: Mon, 11 May 2026 14:35:16 +0200 Subject: [PATCH 6/6] added extra camelCase tests for query headers --- src/tests/test_dynamic_api/test_filters.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tests/test_dynamic_api/test_filters.py b/src/tests/test_dynamic_api/test_filters.py index 94e0d6e40..574e59719 100644 --- a/src/tests/test_dynamic_api/test_filters.py +++ b/src/tests/test_dynamic_api/test_filters.py @@ -148,6 +148,8 @@ def test_dso_headers_overwrite_query(self, movies_model, movie1, movie2): "DSO-status": "active", "DSO-aantal-bouwlagen.gt": 2, "DSO-aantal-bouwlagen.lt": 10, + "DSO-per-jaar-per-m2.isnull": "true", + "DSO-numbers-33-in-the-middle-44.like": "somestring", "other-header": "ignore-me", }, { @@ -155,6 +157,8 @@ def test_dso_headers_overwrite_query(self, movies_model, movie1, movie2): "status[eq]": ["active"], "aantalBouwlagen[gt]": [2], "aantalBouwlagen[lt]": [10], + "perJaarPerM2[isnull]": ["true"], + "numbers33InTheMiddle44[like]": ["somestring"], }, ), (