simple-module-core 0.0.17__tar.gz → 0.0.19__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/PKG-INFO +3 -3
  2. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/README.md +2 -2
  3. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/pyproject.toml +1 -1
  4. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/__init__.py +3 -0
  5. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_module.py +1 -0
  6. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/module.py +19 -0
  7. simple_module_core-0.0.19/simple_module_core/public_routes.py +129 -0
  8. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/services.py +2 -0
  9. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_module_base.py +24 -0
  10. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_module_diagnostics.py +30 -0
  11. simple_module_core-0.0.19/tests/test_public_routes.py +94 -0
  12. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_services.py +3 -0
  13. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/.gitignore +0 -0
  14. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/LICENSE +0 -0
  15. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/__main__.py +0 -0
  16. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/__init__.py +0 -0
  17. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_coupling.py +0 -0
  18. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_i18n.py +0 -0
  19. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_inertia_api.py +0 -0
  20. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_js_workspace.py +0 -0
  21. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_migration.py +0 -0
  22. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_pages.py +0 -0
  23. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_runner.py +0 -0
  24. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/diagnostics/_types.py +0 -0
  25. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/discovery.py +0 -0
  26. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/dotenv.py +0 -0
  27. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/environments.py +0 -0
  28. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/events.py +0 -0
  29. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/exceptions.py +0 -0
  30. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/feature_flags.py +0 -0
  31. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/health.py +0 -0
  32. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/i18n.py +0 -0
  33. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/menu.py +0 -0
  34. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/permissions.py +0 -0
  35. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/py.typed +0 -0
  36. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/simple_module_core/versioning.py +0 -0
  37. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_diagnostics.py +0 -0
  38. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_discovery.py +0 -0
  39. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_dotenv.py +0 -0
  40. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_environments.py +0 -0
  41. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_events.py +0 -0
  42. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_feature_flags.py +0 -0
  43. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_feature_flags_decorator.py +0 -0
  44. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_health_registry.py +0 -0
  45. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_i18n.py +0 -0
  46. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_i18n_diagnostics.py +0 -0
  47. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_menu.py +0 -0
  48. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_permissions.py +0 -0
  49. {simple_module_core-0.0.17 → simple_module_core-0.0.19}/tests/test_versioning.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simple_module_core
3
- Version: 0.0.17
3
+ Version: 0.0.19
4
4
  Summary: Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  Module-system primitives for the [simple_module](https://github.com/antosubash/simple_module_python) framework — a modular-monolith for Python/FastAPI where each feature is a plugin package discovered at boot.
35
35
 
36
- This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM017`) used by `make doctor`.
36
+ This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM021`) used by `make doctor`.
37
37
 
38
38
  ## Install
39
39
 
@@ -48,7 +48,7 @@ You usually don't install this directly — it's pulled in by `simple_module_hos
48
48
  - `ModuleBase` — the subclass every module extends to opt into lifecycle hooks.
49
49
  - `ModuleMeta` — required `meta = ModuleMeta(name=..., depends_on=...)` attribute on each module.
50
50
  - `discover_modules()` — loads all `[project.entry-points.simple_module]` modules, topologically sorts by `depends_on`.
51
- - Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and ~ten others.
51
+ - Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and the rest of the `SM0xx` set through `SM021`.
52
52
  - Tiny event-bus (`pyee`) for decoupled module-to-module communication.
53
53
 
54
54
  ## Usage
@@ -2,7 +2,7 @@
2
2
 
3
3
  Module-system primitives for the [simple_module](https://github.com/antosubash/simple_module_python) framework — a modular-monolith for Python/FastAPI where each feature is a plugin package discovered at boot.
4
4
 
5
- This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM017`) used by `make doctor`.
5
+ This package defines `ModuleBase`, the `ModuleMeta` descriptor, the `discover_modules()` entry-point loader, topological dependency sorting, event bus primitives, and the diagnostic codes (`SM001`–`SM021`) used by `make doctor`.
6
6
 
7
7
  ## Install
8
8
 
@@ -17,7 +17,7 @@ You usually don't install this directly — it's pulled in by `simple_module_hos
17
17
  - `ModuleBase` — the subclass every module extends to opt into lifecycle hooks.
18
18
  - `ModuleMeta` — required `meta = ModuleMeta(name=..., depends_on=...)` attribute on each module.
19
19
  - `discover_modules()` — loads all `[project.entry-points.simple_module]` modules, topologically sorts by `depends_on`.
20
- - Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and ~ten others.
20
+ - Diagnostic registry — `SM001` missing meta, `SM003` orphan page, `SM008` duplicate name, `SM009` framework→plugin coupling violation, and the rest of the `SM0xx` set through `SM021`.
21
21
  - Tiny event-bus (`pyee`) for decoupled module-to-module communication.
22
22
 
23
23
  ## Usage
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_core"
3
- version = "0.0.17"
3
+ version = "0.0.19"
4
4
  description = "Module-system primitives for the simple_module framework — ModuleBase, discovery, diagnostics, events"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -33,6 +33,7 @@ from simple_module_core.i18n import I18nRegistry, Translator
33
33
  from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
34
34
  from simple_module_core.module import ModuleBase, ModuleMeta
35
35
  from simple_module_core.permissions import PermissionRegistry
36
+ from simple_module_core.public_routes import PublicRoute, PublicRouteRegistry
36
37
  from simple_module_core.services import Services
37
38
  from simple_module_core.versioning import FRAMEWORK_API_VERSION, check_framework_compatibility
38
39
 
@@ -60,6 +61,8 @@ __all__ = [
60
61
  "ModuleMeta",
61
62
  "NotFoundError",
62
63
  "PermissionRegistry",
64
+ "PublicRoute",
65
+ "PublicRouteRegistry",
63
66
  "Services",
64
67
  "Translator",
65
68
  "ValidationError",
@@ -95,6 +95,7 @@ class ModuleDiagnostics:
95
95
  "register_event_handlers",
96
96
  "register_middleware",
97
97
  "register_health_checks",
98
+ "register_public_routes",
98
99
  "register_exception_handlers",
99
100
  "register_settings",
100
101
  "template_dirs",
@@ -15,6 +15,7 @@ if TYPE_CHECKING:
15
15
  from simple_module_core.health import HealthRegistry
16
16
  from simple_module_core.menu import MenuRegistry
17
17
  from simple_module_core.permissions import PermissionRegistry
18
+ from simple_module_core.public_routes import PublicRouteRegistry
18
19
 
19
20
 
20
21
  @dataclass(frozen=True)
@@ -118,6 +119,24 @@ class ModuleBase(ABC):
118
119
  def register_health_checks(self, registry: HealthRegistry) -> None:
119
120
  """Contribute health checks for the ``/health/ready`` endpoint."""
120
121
 
122
+ def register_public_routes(self, registry: PublicRouteRegistry) -> None:
123
+ """Declare routes that must bypass authentication (anonymous access).
124
+
125
+ ``AuthMiddleware`` gates every request behind the active auth provider.
126
+ Override this hook to exempt read-only or webhook routes that are meant
127
+ to be reached without a session — e.g. a STAC / OGC API surface::
128
+
129
+ def register_public_routes(self, registry):
130
+ registry.add_prefix("/api/gis/stac")
131
+ registry.add_regex(
132
+ r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"}
133
+ )
134
+
135
+ Rules are method-aware, so a GET read route nested under a prefix that
136
+ also carries POST/PATCH mutations can be exempted without opening the
137
+ mutations. Called once at boot, in dependency order.
138
+ """
139
+
121
140
  def register_middleware(self, app: FastAPI) -> None:
122
141
  """Add middleware to the application.
123
142
 
@@ -0,0 +1,129 @@
1
+ """Public-route registry — modules declare routes the auth layer must NOT gate.
2
+
3
+ ``AuthMiddleware`` gates every request behind the active auth provider. Modules
4
+ that expose anonymous read APIs (STAC / OGC API / TileJSON, public webhooks,
5
+ status pages) contribute exemptions here via
6
+ :meth:`~simple_module_core.module.ModuleBase.register_public_routes`. The host
7
+ collects them into one registry at boot and the middleware consults it on every
8
+ request.
9
+
10
+ Unlike the legacy ``AuthProvider.get_public_paths`` contract — a flat tuple of
11
+ prefixes matched with ``str.startswith`` — a :class:`PublicRoute` is
12
+ **method-aware** and supports prefix / exact / suffix / regex matching. That
13
+ lets a module expose ``GET /api/gis/datasets/{id}/tilejson`` while leaving
14
+ ``PATCH``/``POST`` siblings under the same prefix authenticated.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import Iterable
21
+
22
+ _MatchKind = str # one of: "prefix" | "exact" | "suffix" | "regex"
23
+ _VALID_KINDS = ("prefix", "exact", "suffix", "regex")
24
+
25
+
26
+ class PublicRoute:
27
+ """A single anonymous-access rule.
28
+
29
+ Args:
30
+ pattern: The path (or path fragment / regex) to match against
31
+ ``request.url.path``.
32
+ methods: HTTP methods this rule applies to (case-insensitive). ``None``
33
+ (the default) means *any* method — the rule matches every verb.
34
+ kind: How ``pattern`` is interpreted — ``"prefix"`` (default, matches
35
+ any path that starts with it), ``"exact"``, ``"suffix"``, or
36
+ ``"regex"`` (anchored at the start of the path via ``re.match``).
37
+ """
38
+
39
+ __slots__ = ("_regex", "kind", "methods", "pattern")
40
+
41
+ def __init__(
42
+ self,
43
+ pattern: str,
44
+ *,
45
+ methods: Iterable[str] | None = None,
46
+ kind: _MatchKind = "prefix",
47
+ ) -> None:
48
+ if kind not in _VALID_KINDS:
49
+ raise ValueError(f"Unknown match kind {kind!r}; expected one of {_VALID_KINDS}")
50
+ self.pattern = pattern
51
+ self.methods: frozenset[str] | None = (
52
+ None if methods is None else frozenset(m.upper() for m in methods)
53
+ )
54
+ self.kind = kind
55
+ self._regex = re.compile(pattern) if kind == "regex" else None
56
+
57
+ def matches(self, method: str, path: str) -> bool:
58
+ """Return ``True`` if *method* + *path* are exempt under this rule."""
59
+ if self.methods is not None and method.upper() not in self.methods:
60
+ return False
61
+ if self.kind == "prefix":
62
+ return path.startswith(self.pattern)
63
+ if self.kind == "exact":
64
+ return path == self.pattern
65
+ if self.kind == "suffix":
66
+ return path.endswith(self.pattern)
67
+ assert self._regex is not None # kind == "regex"
68
+ return self._regex.match(path) is not None
69
+
70
+ def __repr__(self) -> str:
71
+ methods = "*" if self.methods is None else ",".join(sorted(self.methods))
72
+ return f"PublicRoute({self.pattern!r}, kind={self.kind!r}, methods={methods})"
73
+
74
+
75
+ class PublicRouteRegistry:
76
+ """Aggregates every module's :class:`PublicRoute` rules.
77
+
78
+ Populated once during boot (``register_public_routes`` hook) and read on
79
+ every unauthenticated request by ``AuthMiddleware`` — effectively immutable
80
+ after the registration phase.
81
+ """
82
+
83
+ def __init__(self) -> None:
84
+ self._routes: list[PublicRoute] = []
85
+
86
+ def add(
87
+ self,
88
+ route: PublicRoute | str,
89
+ *,
90
+ methods: Iterable[str] | None = None,
91
+ kind: _MatchKind = "prefix",
92
+ ) -> None:
93
+ """Register a rule — either a prebuilt :class:`PublicRoute` or a pattern.
94
+
95
+ Passing a string builds a :class:`PublicRoute` from ``methods``/``kind``;
96
+ passing a :class:`PublicRoute` ignores those keyword arguments.
97
+ """
98
+ if isinstance(route, PublicRoute):
99
+ self._routes.append(route)
100
+ else:
101
+ self._routes.append(PublicRoute(route, methods=methods, kind=kind))
102
+
103
+ def add_prefix(self, prefix: str, *, methods: Iterable[str] | None = None) -> None:
104
+ """Exempt any path starting with *prefix*."""
105
+ self._routes.append(PublicRoute(prefix, methods=methods, kind="prefix"))
106
+
107
+ def add_exact(self, path: str, *, methods: Iterable[str] | None = None) -> None:
108
+ """Exempt exactly *path*."""
109
+ self._routes.append(PublicRoute(path, methods=methods, kind="exact"))
110
+
111
+ def add_suffix(self, suffix: str, *, methods: Iterable[str] | None = None) -> None:
112
+ """Exempt any path ending with *suffix*."""
113
+ self._routes.append(PublicRoute(suffix, methods=methods, kind="suffix"))
114
+
115
+ def add_regex(self, pattern: str, *, methods: Iterable[str] | None = None) -> None:
116
+ """Exempt any path whose start matches *pattern* (``re.match`` semantics)."""
117
+ self._routes.append(PublicRoute(pattern, methods=methods, kind="regex"))
118
+
119
+ def matches(self, method: str, path: str) -> bool:
120
+ """Return ``True`` if any registered rule exempts *method* + *path*."""
121
+ return any(route.matches(method, path) for route in self._routes)
122
+
123
+ @property
124
+ def routes(self) -> list[PublicRoute]:
125
+ """All registered rules (a copy — mutating it doesn't affect the registry)."""
126
+ return list(self._routes)
127
+
128
+
129
+ __all__ = ["PublicRoute", "PublicRouteRegistry"]
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
27
27
  from simple_module_core.menu import MenuRegistry
28
28
  from simple_module_core.module import ModuleBase
29
29
  from simple_module_core.permissions import PermissionRegistry
30
+ from simple_module_core.public_routes import PublicRouteRegistry
30
31
 
31
32
 
32
33
  @dataclass(frozen=True, slots=True)
@@ -40,6 +41,7 @@ class Services:
40
41
  permissions: PermissionRegistry
41
42
  feature_flags: FeatureFlagRegistry
42
43
  health_registry: HealthRegistry
44
+ public_routes: PublicRouteRegistry
43
45
  i18n_registry: I18nRegistry
44
46
  inertia_config: InertiaConfig
45
47
  modules: tuple[ModuleBase, ...]
@@ -101,6 +101,30 @@ class TestModuleNewHooks:
101
101
  mod = DummyModule()
102
102
  mod.register_settings(None)
103
103
 
104
+ async def test_register_public_routes_default_noop(self):
105
+ from simple_module_core.public_routes import PublicRouteRegistry
106
+
107
+ mod = DummyModule()
108
+ reg = PublicRouteRegistry()
109
+ mod.register_public_routes(reg)
110
+ assert reg.routes == []
111
+
112
+ async def test_register_public_routes_override(self):
113
+ from simple_module_core.public_routes import PublicRouteRegistry
114
+
115
+ class ModWithPublic(ModuleBase):
116
+ meta = ModuleMeta(name="WithPublic")
117
+
118
+ def register_public_routes(self, registry):
119
+ registry.add_prefix("/api/with-public/stac")
120
+ registry.add_regex(r"/api/with-public/datasets/[^/]+/tilejson$", methods={"GET"})
121
+
122
+ reg = PublicRouteRegistry()
123
+ ModWithPublic().register_public_routes(reg)
124
+ assert reg.matches("GET", "/api/with-public/stac/collections")
125
+ assert reg.matches("GET", "/api/with-public/datasets/9/tilejson")
126
+ assert not reg.matches("PATCH", "/api/with-public/datasets/9/tilejson")
127
+
104
128
 
105
129
  class TestModuleAssetHooks:
106
130
  async def test_template_dirs_default_empty(self):
@@ -193,6 +193,36 @@ class TestSM018InertiaApiCalls:
193
193
  assert results == []
194
194
 
195
195
 
196
+ class TestSM007EmptyModules:
197
+ """SM007 fires only when a module overrides no registration hooks at all."""
198
+
199
+ def _diags(self, modules):
200
+ from simple_module_core.diagnostics._module import ModuleDiagnostics
201
+
202
+ return list(ModuleDiagnostics()._check_empty_modules(modules))
203
+
204
+ async def test_fires_when_no_hooks_overridden(self):
205
+ from simple_module_core.module import ModuleBase, ModuleMeta
206
+
207
+ class Bare(ModuleBase):
208
+ meta = ModuleMeta(name="Bare")
209
+
210
+ results = self._diags([Bare()])
211
+ assert len(results) == 1
212
+ assert results[0].code == "SM007"
213
+
214
+ async def test_silent_when_only_public_routes_registered(self):
215
+ from simple_module_core.module import ModuleBase, ModuleMeta
216
+
217
+ class PublicOnly(ModuleBase):
218
+ meta = ModuleMeta(name="PublicOnly")
219
+
220
+ def register_public_routes(self, registry):
221
+ registry.add_prefix("/api/public_only/stac")
222
+
223
+ assert self._diags([PublicOnly()]) == []
224
+
225
+
196
226
  class TestSM019ViewsWithoutMenu:
197
227
  """SM019 fires when a module ships view routes but never registers a menu item."""
198
228
 
@@ -0,0 +1,94 @@
1
+ """Tests for PublicRouteRegistry — method-aware anonymous-access rules.
2
+
3
+ The registry is the extension point modules use (via
4
+ ``ModuleBase.register_public_routes``) to declare routes the auth layer must
5
+ let through unauthenticated. Unlike the legacy provider ``get_public_paths``
6
+ contract, a rule can be scoped to specific HTTP methods so a read route nested
7
+ under a mutation-bearing prefix can be exempted without opening the mutations.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from simple_module_core.public_routes import PublicRoute, PublicRouteRegistry
13
+
14
+
15
+ class TestPublicRouteMatching:
16
+ def test_prefix_matches_any_subpath(self):
17
+ route = PublicRoute("/api/gis/stac")
18
+ assert route.matches("GET", "/api/gis/stac")
19
+ assert route.matches("GET", "/api/gis/stac/collections")
20
+ assert not route.matches("GET", "/api/gis/datasets")
21
+
22
+ def test_exact_matches_only_full_path(self):
23
+ route = PublicRoute("/api/gis/catalog/search", kind="exact")
24
+ assert route.matches("GET", "/api/gis/catalog/search")
25
+ assert not route.matches("GET", "/api/gis/catalog/search/extra")
26
+
27
+ def test_suffix_matches_path_tail(self):
28
+ route = PublicRoute("/tilejson", kind="suffix")
29
+ assert route.matches("GET", "/api/gis/datasets/42/tilejson")
30
+ assert not route.matches("GET", "/api/gis/datasets/42/visibility")
31
+
32
+ def test_regex_is_anchored_at_start(self):
33
+ route = PublicRoute(r"/api/gis/datasets/[^/]+/tilejson$", kind="regex")
34
+ assert route.matches("GET", "/api/gis/datasets/42/tilejson")
35
+ assert not route.matches("GET", "/api/gis/datasets/42/tilejson/extra")
36
+ assert not route.matches("GET", "/prefix/api/gis/datasets/42/tilejson")
37
+
38
+ def test_methods_none_matches_every_verb(self):
39
+ route = PublicRoute("/api/gis/stac")
40
+ for method in ("GET", "POST", "PATCH", "DELETE"):
41
+ assert route.matches(method, "/api/gis/stac")
42
+
43
+ def test_methods_restrict_to_listed_verbs(self):
44
+ route = PublicRoute("/api/gis/datasets/", methods={"GET"})
45
+ assert route.matches("GET", "/api/gis/datasets/42/tilejson")
46
+ assert not route.matches("PATCH", "/api/gis/datasets/42/visibility")
47
+ assert not route.matches("POST", "/api/gis/datasets/42/reprocess")
48
+
49
+ def test_method_matching_is_case_insensitive(self):
50
+ route = PublicRoute("/api/gis/stac", methods={"get"})
51
+ assert route.matches("GET", "/api/gis/stac")
52
+
53
+
54
+ class TestPublicRouteRegistry:
55
+ def test_empty_registry_matches_nothing(self):
56
+ registry = PublicRouteRegistry()
57
+ assert not registry.matches("GET", "/api/gis/stac")
58
+
59
+ def test_add_prefix(self):
60
+ registry = PublicRouteRegistry()
61
+ registry.add_prefix("/api/gis/ogc/")
62
+ assert registry.matches("GET", "/api/gis/ogc/collections")
63
+ assert not registry.matches("GET", "/api/gis/datasets")
64
+
65
+ def test_add_exact(self):
66
+ registry = PublicRouteRegistry()
67
+ registry.add_exact("/api/gis/catalog/search")
68
+ assert registry.matches("POST", "/api/gis/catalog/search")
69
+ assert not registry.matches("POST", "/api/gis/catalog/search/x")
70
+
71
+ def test_add_regex_with_method(self):
72
+ registry = PublicRouteRegistry()
73
+ registry.add_regex(r"/api/gis/datasets/[^/]+/tilejson$", methods={"GET"})
74
+ assert registry.matches("GET", "/api/gis/datasets/7/tilejson")
75
+ assert not registry.matches("PATCH", "/api/gis/datasets/7/tilejson")
76
+
77
+ def test_matches_is_true_if_any_route_matches(self):
78
+ registry = PublicRouteRegistry()
79
+ registry.add_prefix("/api/gis/ogc/")
80
+ registry.add_exact("/api/gis/catalog/search")
81
+ assert registry.matches("GET", "/api/gis/ogc/tiles")
82
+ assert registry.matches("GET", "/api/gis/catalog/search")
83
+
84
+ def test_routes_exposes_registered_rules(self):
85
+ registry = PublicRouteRegistry()
86
+ registry.add_prefix("/a")
87
+ registry.add_exact("/b")
88
+ assert len(registry.routes) == 2
89
+ assert all(isinstance(r, PublicRoute) for r in registry.routes)
90
+
91
+ def test_add_accepts_a_prebuilt_route(self):
92
+ registry = PublicRouteRegistry()
93
+ registry.add(PublicRoute("/api/gis/stac"))
94
+ assert registry.matches("GET", "/api/gis/stac")
@@ -29,6 +29,7 @@ class TestServices:
29
29
  assert s.permissions is _SENTINEL_PERMS
30
30
  assert s.feature_flags is _SENTINEL_FLAGS
31
31
  assert s.health_registry is _SENTINEL_HEALTH
32
+ assert s.public_routes is _SENTINEL_PUBLIC_ROUTES
32
33
  assert s.i18n_registry is _SENTINEL_I18N
33
34
  assert s.inertia_config is _SENTINEL_INERTIA
34
35
  assert s.modules == ()
@@ -41,6 +42,7 @@ _SENTINEL_MENU = object()
41
42
  _SENTINEL_PERMS = object()
42
43
  _SENTINEL_FLAGS = object()
43
44
  _SENTINEL_HEALTH = object()
45
+ _SENTINEL_PUBLIC_ROUTES = object()
44
46
  _SENTINEL_I18N = object()
45
47
  _SENTINEL_INERTIA = object()
46
48
 
@@ -55,6 +57,7 @@ def _make_services() -> Services:
55
57
  permissions=_SENTINEL_PERMS, # type: ignore[arg-type]
56
58
  feature_flags=_SENTINEL_FLAGS, # type: ignore[arg-type]
57
59
  health_registry=_SENTINEL_HEALTH, # type: ignore[arg-type]
60
+ public_routes=_SENTINEL_PUBLIC_ROUTES, # type: ignore[arg-type]
58
61
  i18n_registry=_SENTINEL_I18N, # type: ignore[arg-type]
59
62
  inertia_config=_SENTINEL_INERTIA, # type: ignore[arg-type]
60
63
  modules=(),