Repository: komoot/photon
Pull request #653 (2022-03-05 14:44:37)

Simplify handling of tag filter parameters
Instead of grouping tag filter parameters (from the `osm_tag` query parameter) by the type of filter, the new code simply collects all parameters into a list of simple TagFilter objects. They are still sorted by include/exclude filter when building the query but otherwise just strung together in a big or filter. This results in a massive simplification of the code.

The outcome is the same except for the rather curious syntax of `osm_tag=key:!value`. Stringing two of these together with the same key (as in `osm_tag=highway:!motorway&osm_tag=highway:!trunk`) would give you all objects with the given keys except the ones with any of the given values (i.e all highways except motorways and trunks). Now the two queries are or'ed together independently, so you end up getting all objects with the given key (i.e. highways that are not motorways _or_ highways that are not trunks). Given that the `key:!value` syntax was never documented or tested, I don't really see an issue with the change in semantics. The alternative would be to drop that interpretation completely and stick with the documented ones.

The PR also includes some simplifications around the tests for the PhotonRequestFactory, now making heavy use of parametrized tests (thank you, JUnit5) and fixes a few smaller issue around parameter parsing found while completing the tests.


Patch:
diff --git a/src/main/java/de/komoot/photon/elasticsearch/ElasticsearchSearchHandler.java b/src/main/java/de/komoot/photon/elasticsearch/ElasticsearchSearchHandler.java
index 111b028..b75137d 100644
--- a/src/main/java/de/komoot/photon/elasticsearch/ElasticsearchSearchHandler.java
+++ b/src/main/java/de/komoot/photon/elasticsearch/ElasticsearchSearchHandler.java
@@ -56,13 +56,7 @@ public class ElasticsearchSearchHandler implements SearchHandler {
        lastLenient = lenient;
         return PhotonQueryBuilder.
                 builder(photonRequest.getQuery(), photonRequest.getLanguage(), supportedLanguages, lenient).
-                withTags(photonRequest.tags()).
-                withKeys(photonRequest.keys()).
-                withValues(photonRequest.values()).
-                withoutTags(photonRequest.notTags()).
-                withoutKeys(photonRequest.notKeys()).
-                withoutValues(photonRequest.notValues()).
-                withTagsNotValues(photonRequest.tagNotValues()).
+                withOsmTagFilters(photonRequest.getOsmTagFilters()).
                 withLocationBias(photonRequest.getLocationForBias(), photonRequest.getScaleForBias(), photonRequest.getZoomForBias()).
                 withBoundingBox(photonRequest.getBbox());
     }
diff --git a/src/main/java/de/komoot/photon/elasticsearch/PhotonQueryBuilder.java b/src/main/java/de/komoot/photon/elasticsearch/PhotonQueryBuilder.java
index cafc58f..39da173 100644
--- a/src/main/java/de/komoot/photon/elasticsearch/PhotonQueryBuilder.java
+++ b/src/main/java/de/komoot/photon/elasticsearch/PhotonQueryBuilder.java
@@ -3,6 +3,8 @@ package de.komoot.photon.elasticsearch;
 
 import com.vividsolutions.jts.geom.Envelope;
 import com.vividsolutions.jts.geom.Point;
+import de.komoot.photon.searcher.TagFilter;
+import de.komoot.photon.searcher.TagFilterKind;
 import org.elasticsearch.common.lucene.search.function.CombineFunction;
 import org.elasticsearch.common.lucene.search.function.FiltersFunctionScoreQuery.ScoreMode;
 import org.elasticsearch.common.unit.Fuzziness;
@@ -183,119 +185,41 @@ public class PhotonQueryBuilder {
         return this;
     }
 
-    public PhotonQueryBuilder withTags(Map<String, Set<String>> tags) {
-        if (!checkTags(tags)) return this;
-
-        ensureFiltered();
-
-        List<BoolQueryBuilder> termQueries = new ArrayList<BoolQueryBuilder>(tags.size());
-        for (String tagKey : tags.keySet()) {
-            Set<String> valuesToInclude = tags.get(tagKey);
-            TermQueryBuilder keyQuery = QueryBuilders.termQuery("osm_key", tagKey);
-            TermsQueryBuilder valueQuery = QueryBuilders.termsQuery("osm_value", valuesToInclude.toArray(new String[valuesToInclude.size()]));
-            BoolQueryBuilder includeAndQuery = QueryBuilders.boolQuery().must(keyQuery).must(valueQuery);
-            termQueries.add(includeAndQuery);
+    public PhotonQueryBuilder withOsmTagFilters(List<TagFilter> filters) {
+        for (TagFilter filter : filters) {
+            addOsmTagFilter(filter);
         }
-        this.appendIncludeTermQueries(termQueries);
-        return this;
-    }
-
-
-    public PhotonQueryBuilder withKeys(Set<String> keys) {
-        if (!checkTags(keys)) return this;
-
-        ensureFiltered();
-
-        List<TermsQueryBuilder> termQueries = new ArrayList<TermsQueryBuilder>(keys.size());
-        termQueries.add(QueryBuilders.termsQuery("osm_key", keys.toArray()));
-        this.appendIncludeTermQueries(termQueries);
         return this;
     }
 
+    public PhotonQueryBuilder addOsmTagFilter(TagFilter filter) {
+        state = State.FILTERED;
 
-    public PhotonQueryBuilder withValues(Set<String> values) {
-        if (!checkTags(values)) return this;
-
-        ensureFiltered();
-
-        List<TermsQueryBuilder> termQueries = new ArrayList<TermsQueryBuilder>(values.size());
-        termQueries.add(QueryBuilders.termsQuery("osm_value", values.toArray(new String[values.size()])));
-        this.appendIncludeTermQueries(termQueries);
-        return this;
-    }
-
-
-    public PhotonQueryBuilder withTagsNotValues(Map<String, Set<String>> tags) {
-        if (!checkTags(tags)) return this;
-
-        ensureFiltered();
-
-        List<BoolQueryBuilder> termQueries = new ArrayList<BoolQueryBuilder>(tags.size());
-        for (String tagKey : tags.keySet()) {
-            Set<String> valuesToInclude = tags.get(tagKey);
-            TermQueryBuilder keyQuery = QueryBuilders.termQuery("osm_key", tagKey);
-            TermsQueryBuilder valueQuery = QueryBuilders.termsQuery("osm_value", valuesToInclude.toArray(new String[valuesToInclude.size()]));
-
-            BoolQueryBuilder includeAndQuery = QueryBuilders.boolQuery().must(keyQuery).mustNot(valueQuery);
-
-            termQueries.add(includeAndQuery);
-        }
-        this.appendIncludeTermQueries(termQueries);
-        return this;
-    }
-
-
-    public PhotonQueryBuilder withoutTags(Map<String, Set<String>> tagsToExclude) {
-        if (!checkTags(tagsToExclude)) return this;
-
-        ensureFiltered();
-
-        List<QueryBuilder> termQueries = new ArrayList<>(tagsToExclude.size());
-        for (String tagKey : tagsToExclude.keySet()) {
-            Set<String> valuesToExclude = tagsToExclude.get(tagKey);
-            TermQueryBuilder keyQuery = QueryBuilders.termQuery("osm_key", tagKey);
-            TermsQueryBuilder valueQuery = QueryBuilders.termsQuery("osm_value", valuesToExclude.toArray(new String[valuesToExclude.size()]));
-
-            BoolQueryBuilder withoutTagsQuery = QueryBuilders.boolQuery().mustNot(QueryBuilders.boolQuery().must(keyQuery).must(valueQuery));
-
-            termQueries.add(withoutTagsQuery);
+        if (filter.getKind() == TagFilterKind.EXCLUDE_VALUE) {
+            appendIncludeTermQuery(QueryBuilders.boolQuery()
+                    .must(QueryBuilders.termQuery("osm_key", filter.getKey()))
+                    .mustNot(QueryBuilders.termQuery("osm_value", filter.getValue())));
+        } else {
+            QueryBuilder builder;
+            if (filter.isKeyOnly()) {
+                builder = QueryBuilders.termQuery("osm_key", filter.getKey());
+            } else if (filter.isValueOnly()) {
+                builder = QueryBuilders.termQuery("osm_value", filter.getValue());
+            } else {
+                builder = QueryBuilders.boolQuery()
+                        .must(QueryBuilders.termQuery("osm_key", filter.getKey()))
+                        .must(QueryBuilders.termQuery("osm_value", filter.getValue()));
+            }
+            if (filter.getKind() == TagFilterKind.INCLUDE) {
+                appendIncludeTermQuery(builder);
+            } else {
+                appendExcludeTermQuery(builder);
+            }
         }
-
-        this.appendExcludeTermQueries(termQueries);
-
         return this;
     }
 
 
-    public PhotonQueryBuilder withoutKeys(Set<String> keysToExclude) {
-        if (!checkTags(keysToExclude)) return this;
-
-        ensureFiltered();
-
-        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery().mustNot(QueryBuilders.termsQuery("osm_key", keysToExclude.toArray()));
-
-        LinkedList<QueryBuilder> lList = new LinkedList<>();
-        lList.add(boolQuery);
-        this.appendExcludeTermQueries(lList);
-
-        return this;
-    }
-
-
-    public PhotonQueryBuilder withoutValues(Set<String> valuesToExclude) {
-        if (!checkTags(valuesToExclude)) return this;
-
-        ensureFiltered();
-
-        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery().mustNot(QueryBuilders.termsQuery("osm_value", valuesToExclude.toArray()));
-
-        LinkedList<QueryBuilder> lList = new LinkedList<>();
-        lList.add(boolQuery);
-        this.appendExcludeTermQueries(lList);
-
-        return this;
-    }
-
 
     /**
      * When this method is called, all filters are placed inside their containers and the top level filter
@@ -312,7 +236,7 @@ public class PhotonQueryBuilder {
             if (orQueryBuilderForIncludeTagFiltering != null)
                 tagFilters.must(orQueryBuilderForIncludeTagFiltering);
             if (andQueryBuilderForExcludeTagFiltering != null)
-                tagFilters.must(andQueryBuilderForExcludeTagFiltering);
+                tagFilters.mustNot(andQueryBuilderForExcludeTagFiltering);
             finalQueryBuilder.filter(tagFilters);
         }
         
@@ -325,38 +249,21 @@ public class PhotonQueryBuilder {
     }
 
 
-    private Boolean checkTags(Set<String> keys) {
-        return !(keys == null || keys.isEmpty());
-    }
-
-
-    private Boolean checkTags(Map<String, Set<String>> tags) {
-        return !(tags == null || tags.isEmpty());
-    }
-
-
-    private void appendIncludeTermQueries(List<? extends QueryBuilder> termQueries) {
+    private void appendIncludeTermQuery(QueryBuilder termQuery) {
 
         if (orQueryBuilderForIncludeTagFiltering == null)
             orQueryBuilderForIncludeTagFiltering = QueryBuilders.boolQuery();
 
-        for (QueryBuilder eachTagFilter : termQueries)
-            orQueryBuilderForIncludeTagFiltering.should(eachTagFilter);
+        orQueryBuilderForIncludeTagFiltering.should(termQuery);
     }
 
 
-    private void appendExcludeTermQueries(List<QueryBuilder> termQueries) {
+    private void appendExcludeTermQuery(QueryBuilder termQuery) {
 
         if (andQueryBuilderForExcludeTagFiltering == null)
             andQueryBuilderForExcludeTagFiltering = QueryBuilders.boolQuery();
 
-        for (QueryBuilder eachTagFilter : termQueries)
-            andQueryBuilderForExcludeTagFiltering.must(eachTagFilter);
-    }
-
-
-    private void ensureFiltered() {
-        state = State.FILTERED;
+        andQueryBuilderForExcludeTagFiltering.should(termQuery);
     }
 
 
diff --git a/src/main/java/de/komoot/photon/query/BoundingBoxParamConverter.java b/src/main/java/de/komoot/photon/query/BoundingBoxParamConverter.java
index 4e614df..6a8beae 100644
--- a/src/main/java/de/komoot/photon/query/BoundingBoxParamConverter.java
+++ b/src/main/java/de/komoot/photon/query/BoundingBoxParamConverter.java
@@ -11,34 +11,37 @@ import spark.Request;
 public class BoundingBoxParamConverter {
 
     public static final String INVALID_BBOX_ERROR_MESSAGE = "invalid number of supplied coordinates for parameter 'bbox', expected format is: minLon,minLat,maxLon,maxLat";
-    public static final String INVALID_BBOX_BOUNDS_MESSAGE = "Invalid bounds for parameter bbox, expected values minLat, maxLat element [-90,90], minLon, maxLon element [-180,180]";
+    public static final String INVALID_BBOX_BOUNDS_MESSAGE = "Invalid bounds for parameter 'bbox', expected values minLat, maxLat element [-90,90], minLon, maxLon element [-180,180]";
 
     public Envelope apply(Request webRequest) throws BadRequestException {
         String bboxParam = webRequest.queryParams("bbox");
         if (bboxParam == null) {
             return null;
         }
-        Envelope bbox = null;
+
+        String[] bboxCoords = bboxParam.split(",");
+        if (bboxCoords.length != 4) {
+            throw new BadRequestException(400, INVALID_BBOX_ERROR_MESSAGE);
+        }
+
+        return new Envelope(parseDouble(bboxCoords[0], 180),
+                parseDouble(bboxCoords[2], 180),
+                parseDouble(bboxCoords[1], 90),
+                parseDouble(bboxCoords[3], 90));
+    }
+
+    private double parseDouble(String coord, double limit) throws BadRequestException {
+        double result;
         try {
-            String[] bboxCoords = bboxParam.split(",");
-            if (bboxCoords.length != 4) {
-                throw new BadRequestException(400, INVALID_BBOX_ERROR_MESSAGE);
-            }
-            Double minLon = Double.valueOf(bboxCoords[0]);
-            Double minLat = Double.valueOf(bboxCoords[1]);
-            Double maxLon = Double.valueOf(bboxCoords[2]);
-            Double maxLat = Double.valueOf(bboxCoords[3]);
-            if (minLon > 180.0 || minLon < -180.00 || maxLon > 180.0 || maxLon < -180.00) {
-                throw new BadRequestException(400, INVALID_BBOX_BOUNDS_MESSAGE);
-            }
-            if (minLat > 90.0 || minLat < -90.00 || maxLat > 90.0 || maxLat < -90.00) {
-                throw new BadRequestException(400, INVALID_BBOX_BOUNDS_MESSAGE);
-            }
-            bbox = new Envelope(minLon, maxLon, minLat, maxLat);
+            result = Double.parseDouble(coord);
         } catch (NumberFormatException nfe) {
             throw new BadRequestException(400, INVALID_BBOX_ERROR_MESSAGE);
         }
 
-        return bbox;
+        if (Double.isNaN(result) || result < -limit || result > limit) {
+            throw new BadRequestException(400, INVALID_BBOX_BOUNDS_MESSAGE);
+        }
+
+        return result;
     }
 }
diff --git a/src/main/java/de/komoot/photon/query/CheckIfFilteredRequest.java b/src/main/java/de/komoot/photon/query/CheckIfFilteredRequest.java
deleted file mode 100644
index e5a4895..0000000
--- a/src/main/java/de/komoot/photon/query/CheckIfFilteredRequest.java
+++ /dev/null
@@ -1,21 +0,0 @@
-package de.komoot.photon.query;
-
-import spark.QueryParamsMap;
-
-/**
- * Created by Sachin Dole on 2/22/2015.
- */
-public class CheckIfFilteredRequest {
-
-    public boolean execute(QueryParamsMap... operand) {
-        QueryParamsMap queryParam = operand[0];
-        if (!queryParam.hasValue()) return false;
-        String[] tagsToFilterOn = queryParam.values();
-        for (String eachTagToFilterOn : tagsToFilterOn) {
-            if (eachTagToFilterOn != null && eachTagToFilterOn.trim().length() > 0) {
-                return true;
-            }
-        }
-        return false;
-    }
-}
diff --git a/src/main/java/de/komoot/photon/query/PhotonRequest.java b/src/main/java/de/komoot/photon/query/PhotonRequest.java
index e9b2644..d277c46 100644
--- a/src/main/java/de/komoot/photon/query/PhotonRequest.java
+++ b/src/main/java/de/komoot/photon/query/PhotonRequest.java
@@ -2,55 +2,36 @@ package de.komoot.photon.query;
 
 import com.vividsolutions.jts.geom.Envelope;
 import com.vividsolutions.jts.geom.Point;
+import de.komoot.photon.searcher.TagFilter;
 
-import java.io.Serializable;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
+import java.util.*;
 
 /**
  * Collection of query parameters for a search request.
  */
-public class PhotonRequest implements Serializable {
-    private String query;
-    private Integer limit;
-    private Point locationForBias;
-    private String language;
-    private final double scale;
-    private final int zoom;
-    private Envelope bbox;
-    private boolean debug;
-
-    private Set<String> excludeKeys;
-    private Set<String> includeKeys;
-    private Set<String> excludeValues;
-    private Set<String> includeValues;
-    private Map<String, Set<String>> includeTags;
-    private Map<String, Set<String>> excludeTags;
-    private Map<String, Set<String>> excludeTagValues;
-
-
-    public PhotonRequest(String query, int limit, Envelope bbox, Point locationForBias, double scale, int zoom, String language, boolean debug) {
+public class PhotonRequest {
+    private final String query;
+    private final String language;
+    private int limit = 15;
+    private Point locationForBias = null;
+    private double scale = 0.2;
+    private int zoom = 14;
+    private Envelope bbox = null;
+    private boolean debug = false;
+
+    private final List<TagFilter> osmTagFilters = new ArrayList<>(1);
+
+
+    public PhotonRequest(String query, String language) {
         this.query = query;
-        this.limit = limit;
-        this.locationForBias = locationForBias;
-        this.scale = scale;
-        this.zoom = zoom;
         this.language = language;
-        this.bbox = bbox;
-        this.debug = debug;
-    }
-
-    public PhotonRequest(String query) {
-        this(query, 10, null, null, 0.2, 14, "default", false);
     }
 
     public String getQuery() {
         return query;
     }
 
-    public Integer getLimit() {
+    public int getLimit() {
         return limit;
     }
     
@@ -76,136 +57,52 @@ public class PhotonRequest implements Serializable {
 
     public boolean getDebug() { return debug; }
 
-    public Set<String> keys() {
-        return includeKeys;
-    }
-
-    public Set<String> values() {
-        return includeValues;
-    }
-
-    public Map<String, Set<String>> tags() {
-        return includeTags;
-    }
-
-    public Set<String> notValues() {
-        return excludeValues;
-    }
-
-    public Map<String, Set<String>> notTags() {
-        return excludeTags;
-    }
-
-    public Set<String> notKeys() {
-        return excludeKeys;
-    }
-
-    public Map<String, Set<String>> tagNotValues() {
-        return excludeTagValues;
-
+    public List<TagFilter> getOsmTagFilters() {
+        return osmTagFilters;
     }
 
-    private void addExcludeValues(String value) {
-        if (excludeValues == null) {
-            excludeValues = new HashSet<>(3);
-        }
-        excludeValues.add(value);
+    PhotonRequest addOsmTagFilter(TagFilter filter) {
+        osmTagFilters.add(filter);
+        return this;
     }
 
-    private void addIncludeValues(String value) {
-        if (includeValues == null) {
-            includeValues = new HashSet<>(3);
+    PhotonRequest setLimit(Integer limit) {
+        if (limit != null) {
+            this.limit = Integer.max(Integer.min(limit, 50), 1);
         }
-        includeValues.add(value);
+        return this;
     }
 
-    private void addExcludeKeys(String value) {
-        if (excludeKeys == null) {
-            excludeKeys = new HashSet<>(3);
+    PhotonRequest setLocationForBias(Point locationForBias) {
+        if (locationForBias != null) {
+            this.locationForBias = locationForBias;
         }
-        excludeKeys.add(value);
+        return this;
     }
 
-    private void addIncludeKeys(String value) {
-        if (includeKeys == null) {
-            includeKeys = new HashSet<>(3);
+    PhotonRequest setScale(Double scale) {
+        if (scale != null) {
+            this.scale = Double.max(Double.min(scale, 1.0), 0.0);
         }
-        includeKeys.add(value);
+        return this;
     }
 
-    private void addExcludeTags(String key, String value) {
-        if (excludeTags == null) {
-            excludeTags = new HashMap<>(3);
+    PhotonRequest setZoom(Integer zoom) {
+        if (zoom != null) {
+            this.zoom = Integer.max(Integer.min(zoom, 18), 0);
         }
-
-        excludeTags.computeIfAbsent(key, k -> new HashSet<>()).add(value);
+        return this;
     }
 
-    private void addExcludeTagValues(String key, String value) {
-        if (excludeTagValues == null) {
-            excludeTagValues = new HashMap<>(3);
+    PhotonRequest setBbox(Envelope bbox) {
+        if (bbox != null) {
+            this.bbox = bbox;
         }
-
-        excludeTagValues.computeIfAbsent(key, k -> new HashSet<>()).add(value);
+        return this;
     }
 
-    private void addIncludeTags(String key, String value) {
-        if (includeTags == null) {
-            includeTags = new HashMap<>(3);
-        }
-
-        includeTags.computeIfAbsent(key, k -> new HashSet<>()).add(value);
-    }
-
-   public void setUpTagFilters(String[] tagFilters) {
-        for (String tagFilter : tagFilters) {
-            if (tagFilter.contains(":")) {
-                //might be tag and value OR just value.
-                if (tagFilter.startsWith("!")) {
-                    //exclude
-                    String keyValueCandidate = tagFilter.substring(1);
-                    if (keyValueCandidate.startsWith(":")) {
-                        //just value
-                        addExcludeValues(keyValueCandidate.substring(1));
-                    } else {
-                        //key and value
-                        String[] keyAndValue = keyValueCandidate.split(":");
-                        String excludeKey = keyAndValue[0];
-                        String value = keyAndValue[1].startsWith("!") ? keyAndValue[1].substring(1) : keyAndValue[1];
-                        addExcludeTags(excludeKey, value);
-                    }
-                } else {
-                    //include key, not sure about value
-                    if (tagFilter.startsWith(":")) {
-                        //just value
-
-                        String valueCandidate = tagFilter.substring(1);
-                        if (valueCandidate.startsWith("!")) {
-                            addExcludeValues(valueCandidate.substring(1));
-                        } else {
-                            addIncludeValues(valueCandidate);
-                        }
-                    } else {
-                        //key and value
-                        String[] keyAndValue = tagFilter.split(":");
-
-                        String key = keyAndValue[0];
-                        String value = keyAndValue[1];
-                        if (value.startsWith("!")) {
-                            addExcludeTagValues(key, value.substring(1));
-                        } else {
-                            addIncludeTags(key, value);
-                        }
-                    }
-                }
-            } else {
-                //only tag
-                if (tagFilter.startsWith("!")) {
-                    addExcludeKeys(tagFilter.substring(1));
-                } else {
-                    addIncludeKeys(tagFilter);
-                }
-            }
-        }
+    PhotonRequest enableDebug() {
+        this.debug = true;
+        return this;
     }
 }
diff --git a/src/main/java/de/komoot/photon/query/PhotonRequestFactory.java b/src/main/java/de/komoot/photon/query/PhotonRequestFactory.java
index 3916ccf..073a224 100644
--- a/src/main/java/de/komoot/photon/query/PhotonRequestFactory.java
+++ b/src/main/java/de/komoot/photon/query/PhotonRequestFactory.java
@@ -1,7 +1,6 @@
 package de.komoot.photon.query;
 
-import com.vividsolutions.jts.geom.Envelope;
-import com.vividsolutions.jts.geom.Point;
+import de.komoot.photon.searcher.TagFilter;
 import spark.QueryParamsMap;
 import spark.Request;
 
@@ -11,7 +10,6 @@ import java.util.List;
 
 /**
  * A factory that creates a {@link PhotonRequest} from a {@link Request web request}
- * Created by Sachin Dole on 2/12/2015.
  */
 public class PhotonRequestFactory {
     private final RequestLanguageResolver languageResolver;
@@ -27,56 +25,72 @@ public class PhotonRequestFactory {
     }
 
     public PhotonRequest create(Request webRequest) throws BadRequestException {
-
-
         for (String queryParam : webRequest.queryParams())
             if (!REQUEST_QUERY_PARAMS.contains(queryParam))
                 throw new BadRequestException(400, "unknown query parameter '" + queryParam + "'.  Allowed parameters are: " + REQUEST_QUERY_PARAMS);
 
-        String language = languageResolver.resolveRequestedLanguage(webRequest);
-
         String query = webRequest.queryParams("q");
-        if (query == null) throw new BadRequestException(400, "missing search term 'q': /?q=berlin");
-        int limit;
-        try {
-            limit = Integer.valueOf(webRequest.queryParams("limit"));
-        } catch (NumberFormatException e) {
-            limit = 15;
+        if (query == null) {
+            throw new BadRequestException(400, "missing search term 'q': /?q=berlin");
         }
 
-        Point locationForBias = optionalLocationParamConverter.apply(webRequest);
-        Envelope bbox = bboxParamConverter.apply(webRequest);
+        PhotonRequest request = new PhotonRequest(query, languageResolver.resolveRequestedLanguage(webRequest));
 
-        double scale = 0.2;
-        String scaleStr = webRequest.queryParams("location_bias_scale");
-        if (scaleStr != null && !scaleStr.isEmpty())
-            try {
-                scale = Double.parseDouble(scaleStr);
-            } catch (Exception nfe) {
-                throw new BadRequestException(400, "invalid parameter 'location_bias_scale' must be a number");
+        request.setLimit(parseInt(webRequest, "limit"));
+        request.setLocationForBias(optionalLocationParamConverter.apply(webRequest));
+        request.setBbox(bboxParamConverter.apply(webRequest));
+        request.setScale(parseDouble(webRequest, "location_bias_scale"));
+        request.setZoom(parseInt(webRequest, "zoom"));
+
+        if (webRequest.queryParams("debug") != null) {
+            request.enableDebug();
+        }
+
+        QueryParamsMap tagFiltersQueryMap = webRequest.queryMap("osm_tag");
+        if (tagFiltersQueryMap.hasValue()) {
+            for (String filter : tagFiltersQueryMap.values()) {
+                TagFilter tagFilter = TagFilter.buildOsmTagFilter(filter);
+                if (tagFilter == null) {
+                    throw new BadRequestException(400, String.format("Invalid parameter 'osm_tag=%s': bad syntax for tag filter.", filter));
+                }
+                request.addOsmTagFilter(TagFilter.buildOsmTagFilter(filter));
             }
+        }
+
+        return request;
+    }
 
-        int zoom = 14;
-        String zoomStr = webRequest.queryParams("zoom");
-        if (zoomStr != null && !zoomStr.isEmpty()) {
+    private Integer parseInt(Request webRequest, String param) throws BadRequestException {
+        Integer intVal = null;
+        String value = webRequest.queryParams(param);
+
+        if (value != null && !value.isEmpty()) {
             try {
-                zoom = Integer.parseInt(zoomStr);
+                intVal = Integer.valueOf(value);
             } catch (NumberFormatException e) {
-                throw new BadRequestException(400, "Invalid parameter 'zoom'. Must be a number.");
+                throw new BadRequestException(400, String.format("Invalid parameter '%s': must be a number", param));
             }
         }
 
-        boolean debug = webRequest.queryParams("debug") != null;
+        return intVal;
+    }
 
-        PhotonRequest request = new PhotonRequest(query, limit, bbox, locationForBias, scale, zoom, language, debug);
+    private Double parseDouble(Request webRequest, String param) throws BadRequestException {
+        Double outVal = null;
+        String value = webRequest.queryParams(param);
 
-        QueryParamsMap tagFiltersQueryMap = webRequest.queryMap("osm_tag");
-        if (new CheckIfFilteredRequest().execute(tagFiltersQueryMap)) {
-            request.setUpTagFilters(tagFiltersQueryMap.values());
-        }
+        if (value != null && !value.isEmpty()) {
+            try {
+                outVal = Double.valueOf(value);
+            } catch (NumberFormatException e) {
+                throw new BadRequestException(400, String.format("Invalid parameter '%s': must be a number", param));
+            }
 
+            if (outVal.isNaN()) {
+                throw new BadRequestException(400, String.format("Invalid parameter '%s': NaN is not allowed", param));
+            }
+        }
 
-        return request;
+        return outVal;
     }
-
 }
diff --git a/src/main/java/de/komoot/photon/searcher/TagFilter.java b/src/main/java/de/komoot/photon/searcher/TagFilter.java
new file mode 100644
index 0000000..1b0cbc1
--- /dev/null
+++ b/src/main/java/de/komoot/photon/searcher/TagFilter.java
@@ -0,0 +1,101 @@
+package de.komoot.photon.searcher;
+
+import java.util.Objects;
+
+public class TagFilter {
+    private final TagFilterKind kind;
+    private final String key;
+    private final String value;
+
+    public TagFilter(TagFilterKind kind, String key, String value) {
+        this.kind = kind;
+        this.key = key;
+        this.value = value;
+    }
+
+    public TagFilterKind getKind() {
+        return kind;
+    }
+
+    public String getKey() {
+        return key;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public boolean isKeyOnly() {
+        return value == null;
+    }
+
+    public boolean isValueOnly() {
+        return key == null;
+    }
+
+    /**
+     * Create a new tag filter from a osm-tag filter description.
+     *
+     * @param filter  Tag filter description.
+     *
+     * @return The appropriate tag filter object or null if the filter string has an invalid format.
+     */
+    public static TagFilter buildOsmTagFilter(String filter) {
+        TagFilterKind kind = null;
+        String key = null;
+        String value = null;
+
+        String[] parts = filter.split(":");
+
+        if (parts.length == 2) {
+            boolean excludeKey = parts[0].startsWith("!");
+            boolean excludeValue = parts[1].startsWith("!");
+
+            key = (excludeKey ? parts[0].substring(1) : parts[0]).trim();
+            if (key.isEmpty()) {
+                key = null;
+            }
+            value = (excludeValue ? parts[1].substring(1) : parts[1]).trim();
+
+            if (!value.isEmpty()) {
+                if (key != null && !excludeKey && excludeValue) {
+                    kind = TagFilterKind.EXCLUDE_VALUE;
+                } else {
+                    kind = excludeKey || excludeValue ? TagFilterKind.EXCLUDE : TagFilterKind.INCLUDE;
+                }
+            }
+        } else if (parts.length == 1 && parts[0].equals(filter)) {
+            boolean exclude = filter.startsWith("!");
+
+            key = exclude ? filter.substring(1) : filter;
+
+            if (!key.isEmpty()) {
+                kind = exclude ? TagFilterKind.EXCLUDE : TagFilterKind.INCLUDE;
+            }
+        }
+
+        return (kind == null) ? null : new TagFilter(kind, key, value);
+    }
+
+    @Override
+    public String toString() {
+        return "TagFilter{" +
+                "kind=" + kind +
+                ", key='" + key + '\'' +
+                ", value='" + value + '\'' +
+                '}';
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        TagFilter tagFilter = (TagFilter) o;
+        return kind == tagFilter.kind && Objects.equals(key, tagFilter.key) && Objects.equals(value, tagFilter.value);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(kind, key, value);
+    }
+}
diff --git a/src/main/java/de/komoot/photon/searcher/TagFilterKind.java b/src/main/java/de/komoot/photon/searcher/TagFilterKind.java
new file mode 100644
index 0000000..5b8240a
--- /dev/null
+++ b/src/main/java/de/komoot/photon/searcher/TagFilterKind.java
@@ -0,0 +1,7 @@
+package de.komoot.photon.searcher;
+
+public enum TagFilterKind {
+    INCLUDE,
+    EXCLUDE,
+    EXCLUDE_VALUE;
+}


Test patch:
diff --git a/src/test/java/de/komoot/photon/query/PhotonRequestFactoryTest.java b/src/test/java/de/komoot/photon/query/PhotonRequestFactoryTest.java
index e685c60..6363d17 100644
--- a/src/test/java/de/komoot/photon/query/PhotonRequestFactoryTest.java
+++ b/src/test/java/de/komoot/photon/query/PhotonRequestFactoryTest.java
@@ -3,296 +3,159 @@ package de.komoot.photon.query;
 import com.vividsolutions.jts.geom.Envelope;
 
 import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+import de.komoot.photon.searcher.TagFilter;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.mockito.Mockito;
 import spark.QueryParamsMap;
 import spark.Request;
 
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
+import java.util.*;
+import java.util.stream.Stream;
 
 /**
- * Created by Sachin Dole on 2/12/2015.
+ * Tests for correct parsing of the query parameters into a PhotonRequest.
  */
 public class PhotonRequestFactoryTest {
 
-    private PhotonRequest photonRequest;
-
-    @Test
-    public void testWithLocationBiasAndLimit() throws Exception {
+    private PhotonRequest create(String... queryParams) throws BadRequestException {
         Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        Mockito.when(mockRequest.queryParams("lon")).thenReturn("-87");
-        Mockito.when(mockRequest.queryParams("lat")).thenReturn("41");
-        Mockito.when(mockRequest.queryParams("limit")).thenReturn("5");
-        QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockQueryParamsMap);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        photonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", photonRequest.getQuery());
-        assertEquals(-87, photonRequest.getLocationForBias().getX(), 0);
-        assertEquals(41, photonRequest.getLocationForBias().getY(), 0);
-        assertEquals(new Integer(5), photonRequest.getLimit());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("lon");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("lat");
-    }
 
-    @Test
-    public void testWithoutLocationBias() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        Mockito.when(mockRequest.queryParams("lon")).thenReturn(null);
-        Mockito.when(mockRequest.queryParams("lat")).thenReturn(null);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockQueryParamsMap);
-        photonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", photonRequest.getQuery());
-        assertNull(photonRequest.getLocationForBias());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("lon");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("lat");
-    }
+        Set<String> keys = new HashSet<>();
+        for (int pos = 0; pos < queryParams.length; pos += 2) {
+            Mockito.when(mockRequest.queryParams(queryParams[pos])).thenReturn(queryParams[pos + 1]);
+            keys.add(queryParams[pos]);
+        }
+
+        Mockito.when(mockRequest.queryParams()).thenReturn(keys);
 
-    @Test
-    public void testWithBadBias() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        Mockito.when(mockRequest.queryParams("lon")).thenReturn("bad");
-        Mockito.when(mockRequest.queryParams("lat")).thenReturn("bad");
         QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
         Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockQueryParamsMap);
-        
-        try {
-            PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-            photonRequest = photonRequestFactory.create(mockRequest);
-            fail();
-        } catch (BadRequestException e) {
-            assertEquals("invalid search term 'lat' and/or 'lon', try instead lat=51.5&lon=8.0", e.getMessage());
-        }
-        
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("lon");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("lat");
+
+        PhotonRequestFactory factory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
+        return factory.create(mockRequest);
     }
 
-    @Test
-    public void testWithBadLimit() throws Exception {
+    private PhotonRequest createOsmFilters(String... filterParams) throws BadRequestException {
         Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        Mockito.when(mockRequest.queryParams("limit")).thenReturn(null);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
+        Mockito.when(mockRequest.queryParams("q")).thenReturn("new york");
+
         QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
+        Mockito.when(mockQueryParamsMap.hasValue()).thenReturn(true);
+        Mockito.when(mockQueryParamsMap.values()).thenReturn(filterParams);
         Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockQueryParamsMap);
-        photonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", photonRequest.getQuery());
-        assertEquals(new Integer(15), photonRequest.getLimit());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("limit");
-    }
 
-    @Test
-    public void testWithBadQuery() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn(null);
-        try {
-            PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-            photonRequest = photonRequestFactory.create(mockRequest);
-            fail();
-        } catch (BadRequestException e) {
-            assertEquals("missing search term 'q': /?q=berlin", e.getMessage());
-        }
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
+        PhotonRequestFactory factory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
+        return factory.create(mockRequest);
     }
 
     @Test
-    public void testWithIncludeKeyFilter() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        QueryParamsMap mockOsmTagQueryParm = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockOsmTagQueryParm.values()).thenReturn(new String[]{"aTag"});
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockOsmTagQueryParm);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        Mockito.when(mockOsmTagQueryParm.hasValue()).thenReturn(true);
-        PhotonRequest filteredPhotonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", filteredPhotonRequest.getQuery());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryMap("osm_tag");
-        Mockito.verify(mockOsmTagQueryParm, Mockito.times(2)).values();
-
-        Set<String> expectedValue = new HashSet<>();
-        expectedValue.add("aTag");
-        assertEquals(expectedValue, filteredPhotonRequest.keys());
-    }
+    public void testWithLocationBiasAndLimit() throws Exception {
+        PhotonRequest photonRequest = create("q", "berlin", "lon", "-87", "lat", "41", "limit", "5");
 
-    @Test
-    public void testWithIncludeTagFilter() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        QueryParamsMap mockOsmTagQueryParm = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockOsmTagQueryParm.values()).thenReturn(new String[]{"aTag:aValue"});
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockOsmTagQueryParm);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        Mockito.when(mockOsmTagQueryParm.hasValue()).thenReturn(true);
-        PhotonRequest filteredPhotonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", filteredPhotonRequest.getQuery());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryMap("osm_tag");
-        Mockito.verify(mockOsmTagQueryParm, Mockito.times(2)).values();
-
-        Set<String> expectedValue = new HashSet<>();
-        expectedValue.add("aValue");
-        assertEquals(Collections.singletonMap("aTag", expectedValue), filteredPhotonRequest.tags());
+        assertAll("request",
+                () -> assertEquals("berlin", photonRequest.getQuery()),
+                () -> assertEquals(-87, photonRequest.getLocationForBias().getX(), 0),
+                () -> assertEquals(41, photonRequest.getLocationForBias().getY(), 0),
+                () -> assertEquals(5, photonRequest.getLimit())
+        );
     }
 
     @Test
-    public void testWithIncludeValueFilter() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        QueryParamsMap mockOsmTagQueryParm = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockOsmTagQueryParm.values()).thenReturn(new String[]{":aValue"});
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockOsmTagQueryParm);
-        Mockito.when(mockOsmTagQueryParm.hasValue()).thenReturn(true);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        PhotonRequest filteredPhotonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", filteredPhotonRequest.getQuery());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryMap("osm_tag");
-        Mockito.verify(mockOsmTagQueryParm, Mockito.times(2)).values();
-
-        Set<String> expectedValue = new HashSet<>();
-        expectedValue.add("aValue");
-        assertEquals(expectedValue, filteredPhotonRequest.values());
-    }
+    public void testWithEmptyLimit() throws Exception {
+        PhotonRequest photonRequest = create("q", "berlin", "limit", "");
 
-    @Test
-    public void testWithExcludeKeyFilter() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        QueryParamsMap mockOsmTagQueryParm = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockOsmTagQueryParm.values()).thenReturn(new String[]{"!aTag"});
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockOsmTagQueryParm);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        Mockito.when(mockOsmTagQueryParm.hasValue()).thenReturn(true);
-        PhotonRequest filteredPhotonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", filteredPhotonRequest.getQuery());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryMap("osm_tag");
-        Mockito.verify(mockOsmTagQueryParm, Mockito.times(2)).values();
-
-        Set<String> expectedValue = new HashSet<>();
-        expectedValue.add("aTag");
-        assertEquals(expectedValue, filteredPhotonRequest.notKeys());
+        assertEquals(15, photonRequest.getLimit());
     }
 
     @Test
-    public void testWithExcludeTagFilter() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        QueryParamsMap mockOsmTagQueryParm = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockOsmTagQueryParm.values()).thenReturn(new String[]{"!aTag:aValue"});
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockOsmTagQueryParm);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        Mockito.when(mockOsmTagQueryParm.hasValue()).thenReturn(true);
-        PhotonRequest filteredPhotonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", filteredPhotonRequest.getQuery());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryMap("osm_tag");
-        Mockito.verify(mockOsmTagQueryParm, Mockito.times(2)).values();
-
-        Set<String> expectedValue = new HashSet<>();
-        expectedValue.add("aValue");
-        assertEquals(Collections.singletonMap("aTag", expectedValue), filteredPhotonRequest.notTags());
+    public void testWithoutLocationBias() throws Exception {
+        PhotonRequest photonRequest = create("q", "berlin");
+
+        assertAll("request",
+                () -> assertEquals("berlin", photonRequest.getQuery()),
+                () -> assertNull(photonRequest.getLocationForBias())
+        );
     }
 
     @Test
-    public void testWithExcludeValueFilter() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("berlin");
-        QueryParamsMap mockOsmTagQueryParm = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockOsmTagQueryParm.values()).thenReturn(new String[]{"!:aValue"});
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockOsmTagQueryParm);
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        Mockito.when(mockOsmTagQueryParm.hasValue()).thenReturn(true);
-        PhotonRequest filteredPhotonRequest = photonRequestFactory.create(mockRequest);
-        assertEquals("berlin", filteredPhotonRequest.getQuery());
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("q");
-        Mockito.verify(mockRequest, Mockito.times(1)).queryMap("osm_tag");
-        Mockito.verify(mockOsmTagQueryParm, Mockito.times(2)).values();
-
-        Set<String> expectedValue = new HashSet<>();
-        expectedValue.add("aValue");
-        assertEquals(expectedValue, filteredPhotonRequest.notValues());
+    public void testInfiniteScale() throws Exception {
+        PhotonRequest photonRequest = create("q", "berlin", "location_bias_scale", "Infinity");
+
+        assertEquals(1.0, photonRequest.getScaleForBias());
     }
-    
+
     @Test
-    public void testWithBboxFilter() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("hanover");
-        QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockQueryParamsMap);
-        Mockito.when(mockRequest.queryParams("bbox")).thenReturn("9.6,52.3,9.8,52.4");
-        
-        PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-        PhotonRequest photonRequest = photonRequestFactory.create(mockRequest); 
-        
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("bbox");
-        assertEquals(new Envelope(9.6, 9.8, 52.3, 52.4), photonRequest.getBbox());
+    public void testEmptyScale() throws Exception {
+        PhotonRequest photonRequest = create("q", "berlin", "location_bias_scale", "");
+
+        assertEquals(0.2, photonRequest.getScaleForBias());
     }
 
     @Test
-    public void testWithBboxFilterWrongNumberOfInputs() throws Exception {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("hanover");
-        QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockQueryParamsMap);
-        Mockito.when(mockRequest.queryParams("bbox")).thenReturn("9.6,52.3,9.8");
-
-        try {
-            PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-            photonRequestFactory.create(mockRequest);
-            fail();
-        } catch (BadRequestException e) {
-            assertEquals(BoundingBoxParamConverter.INVALID_BBOX_ERROR_MESSAGE, e.getMessage());
-        }
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("bbox");
+    public void testWithDebug() throws Exception {
+        PhotonRequest photonRequest = create("q", "berlin", "debug", "1");
+
+        assertEquals(true, photonRequest.getDebug());
     }
-    
-    @Test
-    public void testWithBadBboxFilterMinLat90() throws Exception {
-        testBoundingBoxResponse(9.6,-92,9.8,14);
+
+    @ParameterizedTest
+    @MethodSource("badParamsProvider")
+    public void testBadParameters(List<String> queryParams, String expectedMessageFragment) {
+        BadRequestException exception = assertThrows(BadRequestException.class,
+                () -> create(queryParams.toArray(new String[0])));
+        assertTrue(exception.getMessage().contains(expectedMessageFragment),
+                   String.format("Error message doesn not contain '%s': %s", expectedMessageFragment, exception.getMessage()));
     }
 
-    @Test
-    public void testWithBadBboxFilterMaxLat90() throws Exception {
-        testBoundingBoxResponse(9.6,14,9.8,91);
+    static Stream<Arguments> badParamsProvider() {
+        return Stream.of(
+                arguments(Arrays.asList("q", "nowhere", "extra", "data"), "'extra'"), // unknown parameter
+                arguments(Arrays.asList("q", "berlin", "limit", "x"), "'limit'"), // limit that is not a number
+                arguments(Arrays.asList("q", "berlin", "location_bias_scale", "-e"), "'location_bias_scale'"), // score that is not a number
+                arguments(Arrays.asList("q", "berlin", "location_bias_scale", "NaN"), "'location_bias_scale'"), // score with NaN
+                arguments(Arrays.asList("q", "berlin", "lon", "3", "lat", "bad"), "'lat'"), // bad latitude parameter
+                arguments(Arrays.asList("q", "berlin", "lon", "bad", "lat", "45"), "'lon'"), // bad longitude parameter
+                arguments(Arrays.asList("lat", "45", "lon", "45"), "'q'"),  // missing query parameter
+                arguments(Arrays.asList("q", "hanover", "bbox", "9.6,52.3,9.8"), "'bbox'"), // bbox, wrong number of inputs
+                arguments(Arrays.asList("q", "hanover", "bbox", "9.6,52.3,NaN,9.8"), "'bbox'"), // bbox, bad parameter (NaN)
+                arguments(Arrays.asList("q", "hanover", "bbox", "9.6,52.3,-Infinity,9.8"), "'bbox'"), // bbox, bad parameter (Inf)
+                arguments(Arrays.asList("q", "hanover", "bbox", "9.6,52.3,r34,9.8"), "'bbox'"), // bbox, bad parameter (garbage)
+                arguments(Arrays.asList("q", "hanover", "bbox", "9.6,-92,9.8,14"), "'bbox'"), // bbox, min lat 90
+                arguments(Arrays.asList("q", "hanover", "bbox", "9.6,14,9.8,91"), "'bbox'"), // bbox, max lat 90
+                arguments(Arrays.asList("q", "hanover", "bbox", "-181, 9, 4, 12"), "'bbox'"), // bbox, min lon 180
+                arguments(Arrays.asList("q", "hanover", "bbox", "12, 9, 181, 12"), "'bbox'") // bbox, max lon 180
+        );
     }
 
     @Test
-    public void testWithBadBboxFilterMinLon180() throws Exception {
-        testBoundingBoxResponse(-181, 9, 4, 12);
+    public void testTagFilters() throws Exception {
+        PhotonRequest photonRequest = createOsmFilters("foo", ":!bar");
+
+        List<TagFilter> result = photonRequest.getOsmTagFilters();
+
+        assertEquals(2, result.size());
+
+        assertAll("filterlist",
+                () -> assertNotNull(result.get(0)),
+                () -> assertNotNull(result.get(1))
+        );
     }
 
     @Test
-    public void testWithBadBboxFilterMaxLon180() throws Exception {
-        testBoundingBoxResponse(12, 9, 181, 12);
+    public void testBadTagFilters() {
+        BadRequestException exception = assertThrows(BadRequestException.class,
+                () -> createOsmFilters("good", "bad:bad:bad"));
     }
 
+    @Test
+    public void testWithBboxFilter() throws Exception {
+        PhotonRequest photonRequest = create("q", "hanover", "bbox", "9.6,52.3,9.8,52.4");
 
-    public void testBoundingBoxResponse(double minLon, double minLat, double maxLon, double maxLat) {
-        Request mockRequest = Mockito.mock(Request.class);
-        Mockito.when(mockRequest.queryParams("q")).thenReturn("hanover");
-        QueryParamsMap mockQueryParamsMap = Mockito.mock(QueryParamsMap.class);
-        Mockito.when(mockRequest.queryMap("osm_tag")).thenReturn(mockQueryParamsMap);
-        Mockito.when(mockRequest.queryParams("bbox")).thenReturn(minLon + "," + minLat + "," + maxLon + "," + maxLat);
-
-        try {
-            PhotonRequestFactory photonRequestFactory = new PhotonRequestFactory(Collections.singletonList("en"), "en");
-            photonRequestFactory.create(mockRequest);
-            fail();
-        } catch (BadRequestException e) {
-            assertEquals(BoundingBoxParamConverter.INVALID_BBOX_BOUNDS_MESSAGE, e.getMessage());
-        }
-        Mockito.verify(mockRequest, Mockito.times(1)).queryParams("bbox");
+        assertEquals(new Envelope(9.6, 9.8, 52.3, 52.4), photonRequest.getBbox());
     }
 }
\ No newline at end of file
diff --git a/src/test/java/de/komoot/photon/query/PhotonRequestTest.java b/src/test/java/de/komoot/photon/query/PhotonRequestTest.java
deleted file mode 100644
index 21c5283..0000000
--- a/src/test/java/de/komoot/photon/query/PhotonRequestTest.java
+++ /dev/null
@@ -1,80 +0,0 @@
-package de.komoot.photon.query;
-
-import static org.junit.jupiter.api.Assertions.*;
-import org.junit.jupiter.api.Test;
-
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.Map;
-import java.util.Set;
-
-public class PhotonRequestTest {
-
-    private PhotonRequest simpleRequest() {
-        return new PhotonRequest("foo", 1, null, null, 1.6, 16, "de", false);
-    }
-
-    @Test
-    public void testIncludePlaces() {
-        PhotonRequest request = simpleRequest();
-        request.setUpTagFilters(new String[]{"key:value", "12!:34"});
-
-        Map<String, Set<String>> tags = request.tags();
-        assertEquals(2, tags.size());
-        assertEquals(new HashSet<>(Arrays.asList("value")), tags.get("key"));
-        assertEquals(new HashSet<>(Arrays.asList("34")), tags.get("12!"));
-    }
-
-    @Test
-    public void testExcludePlaces() {
-        PhotonRequest request = simpleRequest();
-        request.setUpTagFilters(new String[]{"!foo:234", "!foo:!abc"});
-
-        Map<String, Set<String>> tags = request.notTags();
-        assertEquals(1, tags.size());
-        assertEquals(new HashSet<>(Arrays.asList("234", "abc")), tags.get("foo"));
-    }
-
-    @Test
-    public void testIncludeKey() {
-        PhotonRequest request = simpleRequest();
-        request.setUpTagFilters(new String[]{"highway", "path"});
-
-        assertEquals(new HashSet<>(Arrays.asList("highway", "path")), request.keys());
-    }
-
-    @Test
-    public void testExcludeKey() {
-        PhotonRequest request = simpleRequest();
-        request.setUpTagFilters(new String[]{"!amenity", "!place"});
-
-        assertEquals(new HashSet<>(Arrays.asList("amenity", "place")), request.notKeys());
-    }
-
-
-    @Test
-    public void testIncludeValue() {
-        PhotonRequest request = simpleRequest();
-        request.setUpTagFilters(new String[]{":hotel", ":restaurant"});
-
-        assertEquals(new HashSet<>(Arrays.asList("hotel", "restaurant")), request.values());
-    }
-
-    @Test
-    public void testExcludeValue() {
-        PhotonRequest request = simpleRequest();
-        request.setUpTagFilters(new String[]{":!123", ":!1234"});
-
-        assertEquals(new HashSet<>(Arrays.asList("123", "1234")), request.notValues());
-    }
-
-    @Test
-    public void testExcludeTagValues() {
-        PhotonRequest request = simpleRequest();
-        request.setUpTagFilters(new String[]{"foo:!bar"});
-
-        Map<String, Set<String>> tags = request.tagNotValues();
-        assertEquals(1, tags.size());
-        assertEquals(new HashSet<>(Arrays.asList("bar")), tags.get("foo"));
-    }
-}
diff --git a/src/test/java/de/komoot/photon/query/QueryBasicSearchTest.java b/src/test/java/de/komoot/photon/query/QueryBasicSearchTest.java
index fcb140e..4250e82 100644
--- a/src/test/java/de/komoot/photon/query/QueryBasicSearchTest.java
+++ b/src/test/java/de/komoot/photon/query/QueryBasicSearchTest.java
@@ -39,7 +39,7 @@ public class QueryBasicSearchTest extends ESBaseTester {
     }
 
     private List<PhotonResult> search(String query) {
-        return getServer().createSearchHandler(new String[]{"en"}).search(new PhotonRequest(query, 10, null, null, 0.2, 14, "en", false));
+        return getServer().createSearchHandler(new String[]{"en"}).search(new PhotonRequest(query, "en"));
     }
 
 
diff --git a/src/test/java/de/komoot/photon/query/QueryByClassificationTest.java b/src/test/java/de/komoot/photon/query/QueryByClassificationTest.java
index 728e940..0d63d9a 100644
--- a/src/test/java/de/komoot/photon/query/QueryByClassificationTest.java
+++ b/src/test/java/de/komoot/photon/query/QueryByClassificationTest.java
@@ -36,7 +36,7 @@ public class QueryByClassificationTest extends ESBaseTester {
     }
 
     private List<PhotonResult> search(String query) {
-        return getServer().createSearchHandler(new String[]{"en"}).search(new PhotonRequest(query));
+        return getServer().createSearchHandler(new String[]{"en"}).search(new PhotonRequest(query, "en"));
     }
 
     private void updateClassification(String key, String value, String... terms) {
diff --git a/src/test/java/de/komoot/photon/query/QueryByLanguageTest.java b/src/test/java/de/komoot/photon/query/QueryByLanguageTest.java
index e703371..382a022 100644
--- a/src/test/java/de/komoot/photon/query/QueryByLanguageTest.java
+++ b/src/test/java/de/komoot/photon/query/QueryByLanguageTest.java
@@ -42,7 +42,7 @@ public class QueryByLanguageTest extends ESBaseTester {
     }
 
     private List<PhotonResult> search(String query, String lang) {
-        return getServer().createSearchHandler(languageList).search(new PhotonRequest(query, 10, null, null, 0.2, 14, lang, true));
+        return getServer().createSearchHandler(languageList).search(new PhotonRequest(query, lang));
     }
 
     @Test
diff --git a/src/test/java/de/komoot/photon/query/QueryFilterTagValueTest.java b/src/test/java/de/komoot/photon/query/QueryFilterTagValueTest.java
index 29b8981..9d645d6 100644
--- a/src/test/java/de/komoot/photon/query/QueryFilterTagValueTest.java
+++ b/src/test/java/de/komoot/photon/query/QueryFilterTagValueTest.java
@@ -5,18 +5,29 @@ import de.komoot.photon.PhotonDoc;
 import de.komoot.photon.Importer;
 
 import java.util.List;
+import java.util.stream.Stream;
 
 import de.komoot.photon.searcher.PhotonResult;
-import org.json.JSONObject;
+import de.komoot.photon.searcher.TagFilter;
 import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
 
 @TestInstance(TestInstance.Lifecycle.PER_CLASS)
 public class QueryFilterTagValueTest extends ESBaseTester {
-    private static final String[] TAGS = new String[]{"tourism", "attraction", "tourism", "hotel", "tourism", "museum",
-                                                      "tourism", "information", "amenity", "parking", "amenity", "restaurant",
-                                                      "amenity", "information", "food", "information", "railway", "station"};
+    private static final String[] TAGS = new String[]{"tourism", "attraction",
+                                                      "tourism", "hotel",
+                                                      "tourism", "museum",
+                                                      "tourism", "information",
+                                                      "amenity", "parking",
+                                                      "amenity", "restaurant",
+                                                      "amenity", "information",
+                                                      "food", "information",
+                                                      "railway", "station"};
 
     @BeforeAll
     public void setUp() throws Exception {
@@ -47,136 +58,53 @@ public class QueryFilterTagValueTest extends ESBaseTester {
         shutdownES();
     }
 
-    private PhotonRequest queryBerlinWithTags(String... params) {
-        PhotonRequest request = new PhotonRequest("berlin", 100, null, null, 0.2, 14, "en", false);
-        request.setUpTagFilters(params);
-
-        return request;
-    }
+    private List<PhotonResult> searchWithTags(String[] params) {
+        PhotonRequest request = new PhotonRequest("berlin", "en").setLimit(50);
+        for (String param : params) {
+            request.addOsmTagFilter(TagFilter.buildOsmTagFilter(param));
+        }
 
-    private List<PhotonResult> search(PhotonRequest request) {
         return getServer().createSearchHandler(new String[]{"en"}).search(request);
     }
 
-    /**
-     * Find me all places named "berlin" that are tagged "tourism=attraction"
-     */
-    @Test
-    public void testFilterWithTagTourismAttraction() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("tourism:attraction"));
-
-        assertEquals(2l, searchResponse.size());
-    }
-
-    /**
-     * Find me all places named "berlin" that are tagged with a value of "attraction".
-     */
-    @Test
-    public void testValueAttraction() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags(":attraction"));
 
-        assertEquals(2l, searchResponse.size());
+    @ParameterizedTest
+    @MethodSource("simpleTagFilterProvider")
+    public void testSingleTagFilter(String filter, int expectedResults) {
+        assertEquals(expectedResults, searchWithTags(new String[]{filter}).size());
     }
 
-    /**
-     * Find me all places named "berlin" that are tagged with key "tourism".
-     */
-    @Test
-    public void testKeyTourism() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("tourism"));
-
-        assertEquals(8l, searchResponse.size());
+    static Stream<Arguments> simpleTagFilterProvider() {
+        return Stream.of(
+                arguments("tourism:attraction", 2),
+                arguments(":attraction", 2),
+                arguments(":information", 6),
+                arguments("tourism", 8),
+                arguments("!tourism:attraction", 16),
+                arguments(":!information", 12),
+                arguments("!tourism", 10),
+                arguments("tourism:!information", 6)
+        );
     }
 
-    /**
-     * Find me all places named "berlin" that are NOT tagged "tourism=attraction"
-     */
-    @Test
-    public void testFilterWithoutTagTourismAttraction() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("!tourism:attraction"));
 
-        assertEquals(16l, searchResponse.size());
+    @ParameterizedTest
+    @MethodSource("combinedTagFilterProvider")
+    public void testCombinedTagFilter(String[] filters, int expectedResults) {
+        assertEquals(expectedResults, searchWithTags(filters).size());
     }
 
-    /**
-     * Find me all places named "berlin" that do not have the value "information" in their TAGS - no matter what key
-     */
-    @Test
-    public void testValueNotInformation() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("!:information"));
-
-        assertEquals(12l, searchResponse.size());
-    }
-
-    /**
-     * Find me all places named "berlin" that do not have the value "information" in their TAGS - no matter what key
-     */
-    @Test
-    public void testValueNotInformationAlt() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags(":!information"));
-
-        assertEquals(12l, searchResponse.size());
-    }
-
-    /**
-     * Find me all places named "berlin" that do not have the key "tourism" in their TAGS
-     */
-    @Test
-    public void testKeyNotTourism() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("!tourism"));
-
-        assertEquals(10l, searchResponse.size());
-    }
-
-    /**
-     * Find me all places named "berlin" that are tagged with the key "tourism" but not tagged with value "information".
-     */
-    @Test
-    public void testKeyTourismAndValueNotInformation() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("tourism:!information"));
-
-        assertEquals(6l, searchResponse.size());
-    }
-
-    /**
-     * Find me all places named "berlin" that are tagged without the keys "tourism" and "amenity".
-     */
-    @Test
-    public void testKeyNotTourismAndKeyNotAmenity() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("!tourism", "!amenity"));
-
-        assertEquals(4l, searchResponse.size());
-    }
-
-    /**
-     * Find me all places named "berlin" that are tagged with the key "tourism" but not "amenity". This test works, but,
-     * the use case does not make sense because by searching for key "tourism", this test already excludes places keyed
-     * on "amenity"
-     */
-    @Test
-    public void testKeyTourismAndKeyNotAmenity() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("tourism", "!amenity"));
-
-        assertEquals(8l, searchResponse.size());
-    }
-
-    /**
-     * Find me all places named "berlin" that have value "information" but not key "amenity"
-     */
-    @Test
-    public void testValueInformationButKeyNotAmenity() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags(":information", "!amenity"));
-
-        assertEquals(4l, searchResponse.size());
-    }
-
-    /**
-     * Find me all places named "berlin" that do not have the tag tourism=attraction
-     */
-    @Test
-    public void testTagNotTourismAttraction() {
-        List<PhotonResult> searchResponse = search(queryBerlinWithTags("!tourism:attraction"));
-
-        assertEquals(16l, searchResponse.size());
+    static Stream<Arguments> combinedTagFilterProvider() {
+        return Stream.of(
+                arguments(new String[]{"food", "amenity"}, 8),
+                arguments(new String[]{":parking", ":museum"}, 4),
+                arguments(new String[]{"food", ":information"}, 6),
+                arguments(new String[]{"!tourism", "!amenity"}, 4),
+                arguments(new String[]{"tourism", "!amenity"}, 8),
+                arguments(new String[]{":information", "!amenity"}, 4),
+                arguments(new String[]{"tourism:!information", "food"}, 8),
+                arguments(new String[]{"tourism:!information", "tourism:!hotel"}, 8),
+                arguments(new String[]{"tourism", "!:information", "food"}, 6)
+        );
     }
 }
diff --git a/src/test/java/de/komoot/photon/searcher/TagFilterTest.java b/src/test/java/de/komoot/photon/searcher/TagFilterTest.java
new file mode 100644
index 0000000..a113b13
--- /dev/null
+++ b/src/test/java/de/komoot/photon/searcher/TagFilterTest.java
@@ -0,0 +1,43 @@
+package de.komoot.photon.searcher;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.params.provider.Arguments.arguments;
+
+class TagFilterTest {
+
+    @ParameterizedTest
+    @MethodSource("validOsmTagFilterValueProvider")
+    public void testBuildOsmTagFilterOk(String filter, TagFilterKind kind, String key, String value) {
+        assertEquals(new TagFilter(kind, key, value),
+                TagFilter.buildOsmTagFilter(filter));
+    }
+
+    static Stream<Arguments> validOsmTagFilterValueProvider() {
+        return Stream.of(
+                arguments("tourism", TagFilterKind.INCLUDE, "tourism", null),
+                arguments(":information", TagFilterKind.INCLUDE, null, "information"),
+                arguments("shop:bakery", TagFilterKind.INCLUDE, "shop", "bakery"),
+                arguments("!highway", TagFilterKind.EXCLUDE, "highway", null),
+                arguments("!:path", TagFilterKind.EXCLUDE, null, "path"),
+                arguments(":!path", TagFilterKind.EXCLUDE, null, "path"),
+                arguments("!highway:path", TagFilterKind.EXCLUDE, "highway", "path"),
+                arguments("!highway:!path", TagFilterKind.EXCLUDE, "highway", "path"),
+                arguments("amenity:!post_box", TagFilterKind.EXCLUDE_VALUE, "amenity", "post_box")
+        );
+    }
+
+
+    @ParameterizedTest
+    @ValueSource(strings = {"", ":", "addr:housenumber:1", "shop:"})
+    public void testBuildOsmTagFilterInvalid(String filter) {
+        assertNull(TagFilter.buildOsmTagFilter(filter));
+    }
+
+}
\ No newline at end of file