Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions src/dso_api/dynamic_api/filters/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions src/templates/dso_api/dynamic_api/docs/rest/filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 95 additions & 1 deletion src/tests/test_dynamic_api/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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,
)


Expand Down Expand Up @@ -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

Comment thread
lotte-amsterdam marked this conversation as resolved.
@pytest.mark.parametrize(
"query,expect",
[
Expand Down
Loading