diff --git a/src/dso_api/dynamic_api/filters/parser.py b/src/dso_api/dynamic_api/filters/parser.py index c09b5fcd6..ff5f28216 100644 --- a/src/dso_api/dynamic_api/filters/parser.py +++ b/src/dso_api/dynamic_api/filters/parser.py @@ -16,6 +16,7 @@ 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 @@ -174,33 +175,78 @@ class QueryFilterEngine: "_csv_header", "_csv_separator", } + HEADER_PARAMS_PREFIX = "Dso-" @classmethod def from_request(cls, request) -> QueryFilterEngine: """Construct the parser from the request data.""" + dso_headers = MultiValueDict() + + for key, value in request.headers.items(): + if not key.startswith(cls.HEADER_PARAMS_PREFIX): + continue + + # custom query headers are passed with a DSO- prefix + raw_key = key[len(cls.HEADER_PARAMS_PREFIX) :] + + # default operator will be "eq" + operator = "eq" + + # 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("-", "") + query_param = toCamelCase(query_part) + + new_key = f"{query_param}[{operator}]" + + dso_headers.setlist(new_key, [value]) + return cls( user_scopes=request.user_scopes, query=request.GET, + dso_headers=dso_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, + dso_headers: MultiValueDict | None = 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, dso_headers) self.request_date = request_date + self.dso_headers = dso_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, + 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: + for key, values in dso_headers.lists(): + # dso_headers are leading + query.setlist(key, values) for key in sorted(query): if key in self.NON_FILTER_PARAMS: 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..af5a5a64e 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. 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.gte +DSO-naam.like +``` + ## 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 f386625e8..574e59719 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 @@ -12,6 +13,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 +38,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=(), dso_headers: MultiValueDict | None = None +) -> parser.QueryFilterEngine: """Simulate creation of a filter engine, based on request data.""" get_params = QueryDict(query_string) + dso_headers = MultiValueDict(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(), + dso_headers=dso_headers, ) @@ -107,6 +113,94 @@ 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_dso_headers(self, movies_model, movie1, movie2): + """Prove that filtering with dso headers work.""" + engine = create_filter_engine( + "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_dso_headers_overwrite_query(self, movies_model, movie1, movie2): + """Prove that filtering with dso headers work.""" + engine = create_filter_engine( + "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} == {"movie1"} + + @pytest.mark.parametrize( + "headers, expected", + [ + ( + { + "DSO-aantal-bouwlagen": 5, + "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", + }, + { + "aantalBouwlagen[eq]": [5], + "status[eq]": ["active"], + "aantalBouwlagen[gt]": [2], + "aantalBouwlagen[lt]": [10], + "perJaarPerM2[isnull]": ["true"], + "numbers33InTheMiddle44[like]": ["somestring"], + }, + ), + ( + { + "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): + """Prove that parsing of dso headers work.""" + request = api_rf.get("/test", headers=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.dso_headers.lists()) == expected + @pytest.mark.parametrize( "query,expect", [