Skip to content

Commit 43d66bf

Browse files
committed
feat: configurable path param regex
1 parent 8b3d3c9 commit 43d66bf

9 files changed

Lines changed: 349 additions & 29 deletions

File tree

docs/user-guide/configuration.md

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,10 @@ These settings configure the CORS behavior when `PROXY_OPTIONS` is `false` (the
323323

324324
### `ITEMS_FILTER_PATH`
325325

326-
: Regex pattern used to identify request paths that require the application of the items filter
326+
: Regex patterns used to identify request paths that require the application of the items filter. See [Filter paths and path params](#filter-paths-and-path-params).
327327

328-
- **Type:** Regex string
329-
- **Required:** No, defaults to `^(/collections/([^/]+)/items(/[^/]+)?$|/search$)`
328+
- **Type:** Regex string, or a JSON array of regex strings
329+
- **Required:** No, defaults to `["^(?:/collections/(?P<collection_id>[^/]+)/items(?:/(?P<item_id>[^/]+))?|/search)$"]`
330330
- **Example:** `^(/collections/([^/]+)/items(/[^/]+)?$|/search$|/custom$)`
331331

332332
### `COLLECTIONS_FILTER_CLS`
@@ -355,8 +355,58 @@ These settings configure the CORS behavior when `PROXY_OPTIONS` is `false` (the
355355

356356
### `COLLECTIONS_FILTER_PATH`
357357

358-
: Regex pattern used to identify request paths that require the application of the collections filter
358+
: Regex patterns used to identify request paths that require the application of the collections filter. See [Filter paths and path params](#filter-paths-and-path-params).
359359

360-
- **Type:** Regex string
361-
- **Required:** No, defaults to `^/collections(/[^/]+)?$`
360+
- **Type:** Regex string, or a JSON array of regex strings
361+
- **Required:** No, defaults to `["^/collections(?:/(?P<collection_id>[^/]+))?$"]`
362362
- **Example:** `^.*?/collections(/[^/]+)?$`
363+
364+
### Filter paths and path params
365+
366+
`ITEMS_FILTER_PATH` and `COLLECTIONS_FILTER_PATH` do two jobs:
367+
368+
1. **Scope**: They select the request paths a filter applies to.
369+
2. **Information**: They declare the request "path parameters" information handed to the filter.
370+
371+
**Path params.** Named capture groups in the pattern that matched become `req.path_params`. A pattern declaring no named groups (the default) instead falls back to built-in extraction, which recognizes `/collections/{collection_id}` optionally followed by `items`, `bulk_items`, or `queryables` and an item ID. Patterns using the fallback are named in a log line at startup.
372+
373+
**Supporting several patterns.** Covering all of your endpoints may require more than one pattern, especially if using named capture groups which do not support redefinition of the group name. To provide more than one pattern, provide the input as a JSON array.
374+
375+
**Authentication.** A path matching either setting always requires authentication, whatever `DEFAULT_PUBLIC` is set to, and is marked accordingly in the OpenAPI spec. A separate `PRIVATE_ENDPOINTS` entry is not needed.
376+
377+
**Example: the Aggregation extension.** The [STAC API Aggregation extension](https://github.com/stac-api-extensions/aggregation) adds four endpoints, which `stac-fastapi` registers as:
378+
379+
| Path | Methods |
380+
| --- | --- |
381+
| `/aggregate` | GET |
382+
| `/aggregations` | GET |
383+
| `/collections/{collection_id}/aggregate` | GET |
384+
| `/collections/{collection_id}/aggregations` | GET |
385+
386+
None are covered by the defaults. The two collection-scoped endpoints belong to the collections filter, and each needs its own pattern because both declare `collection_id`:
387+
388+
```
389+
COLLECTIONS_FILTER_PATH='[
390+
"^/collections(?:/(?P<collection_id>[^/]+))?$",
391+
"^/collections/(?P<collection_id>[^/]+)/aggregate$",
392+
"^/collections/(?P<collection_id>[^/]+)/aggregations$"
393+
]'
394+
```
395+
396+
The two root-level endpoints aggregate across Items, so they belong to the items filter alongside `/search`:
397+
398+
```
399+
ITEMS_FILTER_PATH='[
400+
"^(?:/collections/(?P<collection_id>[^/]+)/items(?:/(?P<item_id>[^/]+))?|/search)$",
401+
"^/aggregate$",
402+
"^/aggregations$"
403+
]'
404+
```
405+
406+
A pattern passes whatever it declares, so a path carrying more variables passes more of them:
407+
408+
```
409+
"^/mosaic/(?P<zoom>[^/]+)/(?P<x>[^/]+)/(?P<y>[^/]+)/(?P<collection_id>[^/]+)\\.png$"
410+
```
411+
412+
gives the filter `zoom`, `x`, `y`, and `collection_id`.

src/stac_auth_proxy/config.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import importlib
44
import json
5+
import re
56
from typing import Annotated, Any, Literal, Optional, Sequence, TypeAlias, Union
67

78
from pydantic import BaseModel, Field, field_validator, model_validator
@@ -28,6 +29,35 @@ def str2list(x: str | Sequence[str] | None) -> Sequence[str] | None:
2829
return x
2930

3031

32+
# NoDecode: pydantic-settings JSON-decodes Sequence fields before validators run,
33+
# which would reject a plain regex string.
34+
FilterPaths: TypeAlias = Annotated[Sequence[str], NoDecode]
35+
36+
37+
def str2patterns(x: str | Sequence[str] | None) -> Sequence[str]:
38+
"""
39+
Parse a filter path or paths setting into a list of regex patterns.
40+
41+
Used as a Pydantic validator, this function supports:
42+
- Single path pattern input as a string
43+
- Multiple path patterns input as a JSON encoded string
44+
"""
45+
if x is None:
46+
return []
47+
48+
if isinstance(x, str):
49+
patterns = json.loads(x) if x.startswith("[") else [x]
50+
else:
51+
patterns = list(x)
52+
53+
for pattern in patterns:
54+
try:
55+
re.compile(pattern)
56+
except re.error as e:
57+
raise ValueError(f"{pattern!r} is not a valid regular expression: {e}")
58+
return patterns
59+
60+
3161
class _ClassInput(BaseModel):
3262
"""Input model for dynamically loading a class or function."""
3363

@@ -129,9 +159,13 @@ class Settings(BaseSettings):
129159

130160
# Filters
131161
items_filter: Optional[_ClassInput] = None
132-
items_filter_path: str = r"^(/collections/([^/]+)/items(/[^/]+)?$|/search$)"
162+
items_filter_path: FilterPaths = [
163+
r"^(?:/collections/(?P<collection_id>[^/]+)/items(?:/(?P<item_id>[^/]+))?|/search)$"
164+
]
133165
collections_filter: Optional[_ClassInput] = None
134-
collections_filter_path: str = r"^/collections(/[^/]+)?$"
166+
collections_filter_path: FilterPaths = [
167+
r"^/collections(?:/(?P<collection_id>[^/]+))?$"
168+
]
135169

136170
model_config = SettingsConfigDict(
137171
env_nested_delimiter="_",
@@ -151,6 +185,17 @@ def parse_audience(cls, v) -> Sequence[str] | None:
151185
"""Parse a comma separated string list of audiences into a list."""
152186
return str2list(v)
153187

188+
@field_validator("items_filter_path", "collections_filter_path", mode="before")
189+
@classmethod
190+
def parse_filter_paths(cls, v) -> Sequence[str]:
191+
"""
192+
Parse the regex patterns identifying paths that a filter applies to.
193+
194+
Named capture groups in a pattern become the ``req.path_params`` passed to
195+
the filter. A pattern declaring none falls back to the built-in extraction.
196+
"""
197+
return str2patterns(v)
198+
154199
@field_validator("root_path_skip_prefixes", mode="before")
155200
@classmethod
156201
def parse_root_path_skip_prefixes(cls, v) -> Sequence[str]:

src/stac_auth_proxy/middleware/AuthenticationExtensionMiddleware.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import re
55
from dataclasses import dataclass, field
6-
from typing import Any, Optional
6+
from typing import Any, Sequence, Union
77
from urllib.parse import urlparse
88

99
from starlette.datastructures import Headers
@@ -35,8 +35,8 @@ class AuthenticationExtensionMiddleware(JsonResponseMiddleware):
3535
"https://stac-extensions.github.io/authentication/v1.1.0/schema.json"
3636
)
3737

38-
items_filter_path: Optional[str] = None
39-
collections_filter_path: Optional[str] = None
38+
items_filter_path: Union[str, Sequence[str], None] = None
39+
collections_filter_path: Union[str, Sequence[str], None] = None
4040
root_path: str = ""
4141

4242
json_content_type_expr: str = r"application/(geo\+)?json"

src/stac_auth_proxy/middleware/Cql2BuildFilterMiddleware.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
import re
55
from dataclasses import dataclass
6-
from typing import Any, Awaitable, Callable, Optional
6+
from typing import Any, Awaitable, Callable, Optional, Sequence, Union
77

88
from cql2 import Expr, ValidationError
99
from fastapi import HTTPException
@@ -32,12 +32,26 @@ class Cql2BuildFilterMiddleware:
3232

3333
# Filters
3434
collections_filter: Optional[Callable] = None
35-
collections_filter_path: str = r"^/collections(/[^/]+)?$"
35+
collections_filter_path: Union[str, Sequence[str]] = (
36+
r"^/collections(?:/(?P<collection_id>[^/]+))?$",
37+
)
3638
items_filter: Optional[Callable] = None
37-
items_filter_path: str = r"^(/collections/([^/]+)/items(/[^/]+)?$|/search$)"
39+
items_filter_path: Union[str, Sequence[str]] = (
40+
r"^(?:/collections/(?P<collection_id>[^/]+)/items(?:/(?P<item_id>[^/]+))?|/search)$",
41+
)
3842

3943
def __post_init__(self):
4044
"""Set required conformances based on the filter functions."""
45+
for attr in ("collections_filter_path", "items_filter_path"):
46+
object.__setattr__(self, attr, requests.as_patterns(getattr(self, attr)))
47+
for pattern in getattr(self, attr):
48+
if not re.compile(pattern).groupindex:
49+
logger.info(
50+
"Filter path %r declares no named capture groups, "
51+
"falling back to built-in path param extraction.",
52+
pattern,
53+
)
54+
4155
required_conformances = set()
4256
if self.collections_filter:
4357
logger.debug("Appending required conformance for collections filter")
@@ -77,7 +91,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
7791
logger.debug("Skipping CQL2 filter build for OPTIONS request")
7892
return await self.app(scope, receive, send)
7993

80-
filter_builder = self._get_filter(request.url.path)
94+
filter_builder, path_params = self._get_filter(request.url.path)
8195
if not filter_builder:
8296
return await self.app(scope, receive, send)
8397

@@ -88,7 +102,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
88102
"path": request.url.path,
89103
"method": request.method,
90104
"query_params": dict(request.query_params),
91-
"path_params": requests.extract_variables(request.url.path),
105+
"path_params": path_params,
92106
"headers": dict(request.headers),
93107
},
94108
**scope["state"],
@@ -112,13 +126,22 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
112126

113127
def _get_filter(
114128
self, path: str
115-
) -> Optional[Callable[..., Awaitable[str | dict[str, Any]]]]:
116-
"""Get the CQL2 filter builder for the given path."""
129+
) -> tuple[Optional[Callable[..., Awaitable[str | dict[str, Any]]]], dict]:
130+
"""Get the CQL2 filter builder for the given path, with its path params."""
117131
endpoint_filters = [
118132
(self.collections_filter_path, self.collections_filter),
119133
(self.items_filter_path, self.items_filter),
120134
]
121-
for expr, builder in endpoint_filters:
122-
if re.match(expr, path):
123-
return builder
124-
return None
135+
for patterns, builder in endpoint_filters:
136+
for expr in patterns:
137+
match = re.match(expr, path)
138+
if match:
139+
return builder, self._path_params(match, path)
140+
return None, {}
141+
142+
@staticmethod
143+
def _path_params(match: re.Match, path: str) -> dict:
144+
"""Get the path params declared by a matched pattern's named groups."""
145+
if match.re.groupindex:
146+
return {k: v for k, v in match.groupdict().items() if v is not None}
147+
return requests.extract_variables(path)

src/stac_auth_proxy/middleware/UpdateOpenApiMiddleware.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import re
44
from dataclasses import dataclass
5-
from typing import Any, Optional
5+
from typing import Any, Optional, Sequence, Union
66

77
from starlette.datastructures import Headers
88
from starlette.requests import Request
@@ -28,8 +28,8 @@ class OpenApiMiddleware(JsonResponseMiddleware):
2828
auth_scheme_name: str = "oidcAuth"
2929
auth_scheme_override: Optional[dict] = None
3030

31-
items_filter_path: Optional[str] = None
32-
collections_filter_path: Optional[str] = None
31+
items_filter_path: Union[str, Sequence[str], None] = None
32+
collections_filter_path: Union[str, Sequence[str], None] = None
3333

3434
json_content_type_expr: str = r"application/(vnd\.oai\.openapi\+json?|json)"
3535

src/stac_auth_proxy/utils/requests.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
import re
66
from dataclasses import dataclass, field
7-
from typing import Dict, Optional, Sequence
7+
from typing import Dict, Optional, Sequence, Union
88
from urllib.parse import urlparse
99

1010
from starlette.requests import Request
@@ -26,6 +26,13 @@ def extract_variables(url: str) -> dict:
2626
return {k: v for k, v in match.groupdict().items() if v} if match else {}
2727

2828

29+
def as_patterns(value: Union[str, Sequence[str], None]) -> Sequence[str]:
30+
"""Normalize a filter path setting to a list of regex patterns."""
31+
if value is None:
32+
return []
33+
return [value] if isinstance(value, str) else list(value)
34+
35+
2936
def dict_to_bytes(d: dict) -> bytes:
3037
"""Convert a dictionary to a body."""
3138
return json.dumps(d, separators=(",", ":")).encode("utf-8")
@@ -56,8 +63,8 @@ def find_match(
5663
private_endpoints: EndpointMethods,
5764
public_endpoints: EndpointMethods,
5865
default_public: bool,
59-
items_filter_path: Optional[str] = None,
60-
collections_filter_path: Optional[str] = None,
66+
items_filter_path: Union[str, Sequence[str], None] = None,
67+
collections_filter_path: Union[str, Sequence[str], None] = None,
6168
) -> "MatchResult":
6269
"""Check if the given path and method match any of the regex patterns and methods in the endpoints."""
6370
primary_endpoints = private_endpoints if default_public else public_endpoints
@@ -70,7 +77,7 @@ def find_match(
7077

7178
# If we have filter paths configured, check those as well (these are always considered to use auth if they match, regardless of default_public)
7279
for filter_path in [items_filter_path, collections_filter_path]:
73-
if filter_path and re.match(filter_path, path):
80+
if any(re.match(pattern, path) for pattern in as_patterns(filter_path)):
7481
return MatchResult(uses_auth=True)
7582

7683
# If default_public and no match found in private_endpoints, it's public

tests/test_config.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,50 @@ def test_cors_model_config():
151151
]
152152
assert cors_settings.allow_methods == ["GET", "POST"]
153153
assert cors_settings.allow_headers == ["Authorization", "Content-Type"]
154+
155+
156+
def test_items_and_collections_path_parameters():
157+
"""Tests related to parsing Collections/Items regexes from inputs."""
158+
common_kwargs = {
159+
"upstream_url": "https://example.com",
160+
"oidc_discovery_url": "https://example.com/.well-known/openid-configuration",
161+
}
162+
163+
# Single pattern case
164+
settings = Settings(
165+
**common_kwargs,
166+
items_filter_path=r"^/collections/([^/]+)/items$",
167+
)
168+
assert settings.items_filter_path == [r"^/collections/([^/]+)/items$"]
169+
170+
# Don't split on commas (valid regex)
171+
settings = Settings(
172+
**common_kwargs,
173+
items_filter_path=r"^/collections/([^/]{2,64})/items$",
174+
)
175+
assert settings.items_filter_path == [r"^/collections/([^/]{2,64})/items$"]
176+
177+
# JSON array decoded into list[str]
178+
settings = Settings(
179+
**common_kwargs,
180+
collections_filter_path='["^/a$", "^/b$"]',
181+
)
182+
assert settings.collections_filter_path == ["^/a$", "^/b$"]
183+
184+
# Directly provided list[str] unaltered
185+
custom_paths = [
186+
r"^/collections(?:/(?P<collection_id>[^/]+))?$",
187+
r"^/collections/(?P<collection_id>[^/]+)/aggregate$",
188+
]
189+
settings = Settings(
190+
**common_kwargs,
191+
collections_filter_path=custom_paths,
192+
)
193+
assert settings.collections_filter_path == custom_paths
194+
195+
# Reject invalid regex at load
196+
with pytest.raises(ValueError, match="not a valid regular expression"):
197+
settings = Settings(
198+
**common_kwargs,
199+
items_filter_path=r"^/collections(?:/(?P<unclosed[^/]+))?$",
200+
)

0 commit comments

Comments
 (0)