Check /conformance for the filtering classes, read /collections/{id}/queryables to learn which properties are filterable, then send a filter parameter with an explicit filter-lang. Build the expression through a small AST rather than by string concatenation — CQL2 text has quoting rules that make naive interpolation both wrong and unsafe.
The OGC API - Features core specification defines exactly two ways to narrow a result set: a bbox parameter and equality on a property name used directly as a query parameter. Everything richer — comparison, ranges, pattern matching, spatial predicates, boolean composition — comes from the Common Query Language extension, which a server may or may not implement.
That has a consequence worth internalising: a server which does not implement filtering does not reject a filter parameter, it ignores it. The response is a valid, successful, unfiltered page of features, and a client that trusts it silently processes the entire collection as though it matched. This is the OGC API equivalent of the empty-result trap in WFS, inverted: instead of getting nothing, you get everything.
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Sequence
import requests
CONF_FILTER = "http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter"
CONF_TEXT = "http://www.opengis.net/spec/cql2/1.0/conf/cql2-text"
CONF_JSON = "http://www.opengis.net/spec/cql2/1.0/conf/cql2-json"
class FilterUnsupported(RuntimeError):
"""Raised rather than letting an ignored filter return everything."""
@dataclass(frozen=True)
class Expr:
"""A CQL2 node. Building an AST is what makes quoting correct by design."""
op: str
args: tuple[Any, ...]
# ── composition ──────────────────────────────────────────────────
def __and__(self, other: "Expr") -> "Expr":
return Expr("and", (self, other))
def __or__(self, other: "Expr") -> "Expr":
return Expr("or", (self, other))
# ── serialisation ────────────────────────────────────────────────
def to_text(self) -> str:
if self.op in {"and", "or"}:
joiner = f" {self.op.upper()} "
return "(" + joiner.join(a.to_text() for a in self.args) + ")"
if self.op == "not":
return f"NOT ({self.args[0].to_text()})"
if self.op == "between":
prop, low, high = self.args
return f"{prop} BETWEEN {_lit(low)} AND {_lit(high)}"
if self.op == "in":
prop, values = self.args
return f"{prop} IN ({', '.join(_lit(v) for v in values)})"
if self.op in {"s_intersects", "t_after", "t_before"}:
prop, value = self.args
return f"{self.op.upper()}({prop}, {value})"
prop, value = self.args
return f"{prop} {self.op} {_lit(value)}"
def to_json(self) -> dict[str, Any]:
def encode(node: Any) -> Any:
if isinstance(node, Expr):
return node.to_json()
return node
if self.op in {"and", "or", "not"}:
return {"op": self.op, "args": [encode(a) for a in self.args]}
prop, *rest = self.args
return {"op": self.op,
"args": [{"property": prop}, *[encode(r) for r in rest]]}
def _lit(value: Any) -> str:
"""Serialise a literal for CQL2 text.
A single quote inside a string literal is escaped by doubling it —
the SQL rule, not the backslash rule. Getting this wrong is both a
syntax error and, on a naive server, an injection vector.
"""
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
if isinstance(value, (int, float)):
return f"{value:g}"
if value is None:
return "NULL"
return "'" + str(value).replace("'", "''") + "'"
# ── convenience constructors ─────────────────────────────────────────
def eq(prop: str, value: Any) -> Expr: return Expr("=", (prop, value))
def gt(prop: str, value: Any) -> Expr: return Expr(">", (prop, value))
def lt(prop: str, value: Any) -> Expr: return Expr("<", (prop, value))
def like(prop: str, pattern: str) -> Expr: return Expr("LIKE", (prop, pattern))
def between(prop: str, low: Any, high: Any) -> Expr:
return Expr("between", (prop, low, high))
def in_(prop: str, values: Sequence[Any]) -> Expr:
return Expr("in", (prop, tuple(values)))
def intersects(prop: str, wkt: str) -> Expr:
return Expr("s_intersects", (prop, wkt))
class FeaturesClient:
def __init__(self, base: str, session: requests.Session | None = None) -> None:
self.base = base.rstrip("/")
self.http = session or requests.Session()
self._conformance: set[str] | None = None
def conformance(self) -> set[str]:
if self._conformance is None:
resp = self.http.get(f"{self.base}/conformance", timeout=30)
resp.raise_for_status()
self._conformance = set(resp.json().get("conformsTo", []))
return self._conformance
def queryables(self, collection: str) -> set[str]:
"""Properties this collection will actually filter on."""
resp = self.http.get(f"{self.base}/collections/{collection}/queryables",
timeout=30, headers={"Accept": "application/schema+json"})
if resp.status_code == 404:
return set()
resp.raise_for_status()
return set((resp.json().get("properties") or {}).keys())
def search(self, collection: str, expr: Expr, limit: int = 500,
lang: str = "cql2-text") -> list[dict[str, Any]]:
"""Refuse to run rather than silently returning an unfiltered page."""
conf = self.conformance()
if CONF_FILTER not in conf:
raise FilterUnsupported(
f"{self.base} does not declare the filter conformance class; "
"a filter parameter would be ignored and you would get everything")
wanted = CONF_TEXT if lang == "cql2-text" else CONF_JSON
if wanted not in conf:
raise FilterUnsupported(f"{self.base} does not declare {lang}")
params = {"limit": str(limit), "filter-lang": lang, "f": "json"}
if lang == "cql2-text":
params["filter"] = expr.to_text()
resp = self.http.get(f"{self.base}/collections/{collection}/items",
params=params, timeout=120)
else:
resp = self.http.post(f"{self.base}/collections/{collection}/search",
params=params, timeout=120,
json={"filter": expr.to_json(),
"filter-lang": "cql2-json"})
resp.raise_for_status()
return resp.json().get("features", [])
if __name__ == "__main__":
client = FeaturesClient("https://example.org/ogcapi")
predicate = (gt("population", 50000)
& eq("country", "O'Brien County")
& intersects("geom", "POLYGON((5 45,11 45,11 48,5 48,5 45))"))
print(predicate.to_text())
print(json.dumps(predicate.to_json(), indent=2)[:200])
The AST is the point. Building CQL2 as a tree and serialising at the end means the quoting rule lives in exactly one function. Composing filters by string formatting means the rule is re-implemented at every call site, and the first value containing an apostrophe — a place name, a surname — produces a syntax error at best.
_lit doubles single quotes, it does not backslash them. CQL2 inherits SQL’s escaping convention, so O'Brien is written 'O''Brien'. A backslash escape is not merely a different style, it is invalid, and on an implementation that concatenates the filter into SQL it is the shape of an injection.
Conformance is checked before the request, not after. search refuses when the server has not declared the filter class, because the alternative outcome — a successful response containing every feature in the collection — is the failure mode that costs the most. Turning a silent superset into an exception is the single most valuable line in the client.
Queryables tell you which properties are filterable. A collection’s queryables document is a JSON Schema listing exactly the properties the server will filter on. A property absent from it may be present on every feature and still unusable in a filter, which is the third branch of the diagnosis and the least obvious one.
Print the serialised forms and confirm the escaping, then compare filtered and unfiltered counts:
((population > 50000 AND country = 'O''Brien County') AND S_INTERSECTS(geom, POLYGON((5 45,11 45,11 48,5 48,5 45))))
all_features = client.search("cities", eq("country", "CH"), limit=1)
big = client.search("cities", eq("country", "CH") & gt("population", 50000), limit=1)
print(len(all_features), len(big)) # a filter that changes nothing is suspicious
If a narrowing filter returns the same count as a broad one, the server is ignoring it — which is exactly what the conformance check exists to prevent, and worth asserting in a test even so.
filter-lang has no reliable default. Some servers assume CQL2 text, some require the parameter, and some reject a filter without it. Sending it explicitly costs nothing and removes the ambiguity entirely.
Text goes in a query string; JSON goes in a body. CQL2 text is URL-encoded into the filter parameter, which means long spatial predicates can exceed a server’s URL length limit. CQL2 JSON is posted to the /search endpoint of the filtering extension, which has no such limit and is the right choice for anything containing a polygon with more than a handful of vertices.
Property names are case-sensitive. They must match the queryables exactly, and a PostGIS-backed collection typically publishes lower-cased names. This is the same trap as SLD filters, and it produces the same symptom: a filter that is simply never true.
Spatial literals are WKT in CRS84. S_INTERSECTS takes a well-known-text geometry whose coordinates are longitude first, regardless of any bbox-crs on the request. Passing a polygon derived from a latitude-first source without swapping produces an empty result that looks like a data problem.
Fetch /conformance and look for the filter conformance class alongside the cql2-text or cql2-json class. This is not optional diligence: a server without the filter class does not reject your filter, it ignores it and returns unfiltered features with a 200, which is far more dangerous than an error.
Text for short predicates written by humans and for anything you want to see in a log; JSON for anything generated, anything containing a geometry, and anything long enough to risk a URL length limit. Since both serialise from the same AST in the code above, the choice is one argument rather than a rewrite.
Usually not. The queryables document is the server’s statement of what it will filter on, and a property outside it is typically ignored or rejected. Where a property you need is missing, that is a service configuration issue rather than a client one — the publisher has to expose it.
For OGC API - Features, yes — it is the filtering language that specification adopted. The older fes filter encoding remains what WFS 2.0 uses, and the two are not interchangeable, which is why a service exposing both interfaces needs a translation layer of the kind described in the endpoint conversion guide.
Back to OGC API - Features and the REST Transition
Related