zubbl-fastapi 0.1.0__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.
- zubbl_fastapi-0.1.0/PKG-INFO +90 -0
- zubbl_fastapi-0.1.0/README.md +61 -0
- zubbl_fastapi-0.1.0/pyproject.toml +64 -0
- zubbl_fastapi-0.1.0/setup.cfg +4 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi/__init__.py +37 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi/endpoints.py +296 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi/identity.py +61 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi/integration.py +368 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi/middleware.py +257 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi/observation.py +41 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi.egg-info/PKG-INFO +90 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi.egg-info/SOURCES.txt +17 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi.egg-info/dependency_links.txt +1 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi.egg-info/requires.txt +7 -0
- zubbl_fastapi-0.1.0/src/zubbl_fastapi.egg-info/top_level.txt +1 -0
- zubbl_fastapi-0.1.0/tests/test_endpoints.py +189 -0
- zubbl_fastapi-0.1.0/tests/test_fastapi_adapter.py +1155 -0
- zubbl_fastapi-0.1.0/tests/test_fastapi_end_to_end.py +187 -0
- zubbl_fastapi-0.1.0/tests/test_identity.py +72 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zubbl-fastapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official FastAPI integration for the Zubbl Runtime Intelligence Platform
|
|
5
|
+
Author-email: Zubbl <support@zubbl.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://zubbl.com
|
|
8
|
+
Project-URL: Documentation, https://zubbl.com
|
|
9
|
+
Keywords: fastapi,security,runtime,application-security,observability,telemetry,behaviour,api-security,zubbl
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Internet
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: fastapi>=0.110.0
|
|
24
|
+
Requires-Dist: zubbl-runtime>=0.1.0
|
|
25
|
+
Provides-Extra: test
|
|
26
|
+
Requires-Dist: httpx>=0.27.0; extra == "test"
|
|
27
|
+
Requires-Dist: pytest>=8.0.0; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "test"
|
|
29
|
+
|
|
30
|
+
# Zubbl FastAPI
|
|
31
|
+
|
|
32
|
+
The official FastAPI integration for the Zubbl Runtime Intelligence Platform.
|
|
33
|
+
|
|
34
|
+
Zubbl provides real-time runtime visibility, behavioural security and adaptive protection from inside your application.
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- 5 minute integration
|
|
39
|
+
- Automatic endpoint discovery
|
|
40
|
+
- Runtime request telemetry
|
|
41
|
+
- Behavioural security signals
|
|
42
|
+
- Runtime protection policies
|
|
43
|
+
- Live Runtime Viewer integration
|
|
44
|
+
- Runtime Guard heartbeat
|
|
45
|
+
- Zero application code changes beyond initial setup
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- Python 3.10+
|
|
50
|
+
- FastAPI 0.110+
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install zubbl-fastapi
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from fastapi import FastAPI
|
|
62
|
+
from zubbl_fastapi import Zubbl
|
|
63
|
+
|
|
64
|
+
app = FastAPI()
|
|
65
|
+
|
|
66
|
+
zubbl = Zubbl("YOUR_API_KEY")
|
|
67
|
+
|
|
68
|
+
zubbl.attach(
|
|
69
|
+
app,
|
|
70
|
+
identify=lambda request: request.state.user.id
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Start your application.
|
|
75
|
+
|
|
76
|
+
Your application will automatically appear inside the Zubbl Runtime Viewer.
|
|
77
|
+
|
|
78
|
+
## Documentation
|
|
79
|
+
|
|
80
|
+
https://zubbl.com
|
|
81
|
+
|
|
82
|
+
## Runtime Intelligence
|
|
83
|
+
|
|
84
|
+
Unlike traditional security tooling, Zubbl observes what happens after authentication.
|
|
85
|
+
|
|
86
|
+
It continuously builds runtime evidence, behavioural intelligence and protection decisions directly from application behaviour.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
Built by Zubbl.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Zubbl FastAPI
|
|
2
|
+
|
|
3
|
+
The official FastAPI integration for the Zubbl Runtime Intelligence Platform.
|
|
4
|
+
|
|
5
|
+
Zubbl provides real-time runtime visibility, behavioural security and adaptive protection from inside your application.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- 5 minute integration
|
|
10
|
+
- Automatic endpoint discovery
|
|
11
|
+
- Runtime request telemetry
|
|
12
|
+
- Behavioural security signals
|
|
13
|
+
- Runtime protection policies
|
|
14
|
+
- Live Runtime Viewer integration
|
|
15
|
+
- Runtime Guard heartbeat
|
|
16
|
+
- Zero application code changes beyond initial setup
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Python 3.10+
|
|
21
|
+
- FastAPI 0.110+
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install zubbl-fastapi
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from fastapi import FastAPI
|
|
33
|
+
from zubbl_fastapi import Zubbl
|
|
34
|
+
|
|
35
|
+
app = FastAPI()
|
|
36
|
+
|
|
37
|
+
zubbl = Zubbl("YOUR_API_KEY")
|
|
38
|
+
|
|
39
|
+
zubbl.attach(
|
|
40
|
+
app,
|
|
41
|
+
identify=lambda request: request.state.user.id
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Start your application.
|
|
46
|
+
|
|
47
|
+
Your application will automatically appear inside the Zubbl Runtime Viewer.
|
|
48
|
+
|
|
49
|
+
## Documentation
|
|
50
|
+
|
|
51
|
+
https://zubbl.com
|
|
52
|
+
|
|
53
|
+
## Runtime Intelligence
|
|
54
|
+
|
|
55
|
+
Unlike traditional security tooling, Zubbl observes what happens after authentication.
|
|
56
|
+
|
|
57
|
+
It continuously builds runtime evidence, behavioural intelligence and protection decisions directly from application behaviour.
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
Built by Zubbl.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zubbl-fastapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official FastAPI integration for the Zubbl Runtime Intelligence Platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Zubbl", email = "support@zubbl.com" }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
license = "MIT"
|
|
17
|
+
|
|
18
|
+
keywords = [
|
|
19
|
+
"fastapi",
|
|
20
|
+
"security",
|
|
21
|
+
"runtime",
|
|
22
|
+
"application-security",
|
|
23
|
+
"observability",
|
|
24
|
+
"telemetry",
|
|
25
|
+
"behaviour",
|
|
26
|
+
"api-security",
|
|
27
|
+
"zubbl"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
classifiers = [
|
|
31
|
+
"Development Status :: 4 - Beta",
|
|
32
|
+
"Framework :: FastAPI",
|
|
33
|
+
"Framework :: AsyncIO",
|
|
34
|
+
"Intended Audience :: Developers",
|
|
35
|
+
"Programming Language :: Python :: 3",
|
|
36
|
+
"Programming Language :: Python :: 3.10",
|
|
37
|
+
"Programming Language :: Python :: 3.11",
|
|
38
|
+
"Programming Language :: Python :: 3.12",
|
|
39
|
+
"Topic :: Internet",
|
|
40
|
+
"Topic :: Security",
|
|
41
|
+
"Topic :: Software Development :: Libraries"
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
dependencies = [
|
|
45
|
+
"fastapi>=0.110.0",
|
|
46
|
+
"zubbl-runtime>=0.1.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[project.urls]
|
|
50
|
+
Homepage = "https://zubbl.com"
|
|
51
|
+
Documentation = "https://zubbl.com"
|
|
52
|
+
[project.optional-dependencies]
|
|
53
|
+
test = [
|
|
54
|
+
"httpx>=0.27.0",
|
|
55
|
+
"pytest>=8.0.0",
|
|
56
|
+
"pytest-asyncio>=0.23.0",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.packages.find]
|
|
60
|
+
where = ["src"]
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
asyncio_mode = "auto"
|
|
64
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from .integration import (
|
|
2
|
+
BlockResponseHandler,
|
|
3
|
+
DEFAULT_EXCLUDED_PATHS,
|
|
4
|
+
IdentityResolver,
|
|
5
|
+
MissingIdentityMode,
|
|
6
|
+
Zubbl,
|
|
7
|
+
ZubblFastAPIError,
|
|
8
|
+
)
|
|
9
|
+
from .endpoints import (
|
|
10
|
+
RouteCatalogue,
|
|
11
|
+
RouteCatalogueDiff,
|
|
12
|
+
RouteDescriptor,
|
|
13
|
+
)
|
|
14
|
+
from .identity import (
|
|
15
|
+
IdentityResolutionError,
|
|
16
|
+
identify_from_state,
|
|
17
|
+
)
|
|
18
|
+
from .middleware import ZubblMiddleware
|
|
19
|
+
from .observation import RuntimeResponseObservation
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"BlockResponseHandler",
|
|
23
|
+
"IdentityResolutionError",
|
|
24
|
+
"identify_from_state",
|
|
25
|
+
"DEFAULT_EXCLUDED_PATHS",
|
|
26
|
+
"IdentityResolver",
|
|
27
|
+
"MissingIdentityMode",
|
|
28
|
+
"RouteDescriptor",
|
|
29
|
+
"RouteCatalogueDiff",
|
|
30
|
+
"RouteCatalogue",
|
|
31
|
+
"RuntimeResponseObservation",
|
|
32
|
+
"Zubbl",
|
|
33
|
+
"ZubblFastAPIError",
|
|
34
|
+
"ZubblMiddleware",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
from fastapi.routing import APIRoute
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalise_path(value: str) -> str:
|
|
13
|
+
cleaned = value.strip()
|
|
14
|
+
|
|
15
|
+
if not cleaned:
|
|
16
|
+
raise ValueError("endpoint path must be a non-empty string")
|
|
17
|
+
|
|
18
|
+
if not cleaned.startswith("/"):
|
|
19
|
+
cleaned = f"/{cleaned}"
|
|
20
|
+
|
|
21
|
+
if len(cleaned) > 1:
|
|
22
|
+
cleaned = cleaned.rstrip("/")
|
|
23
|
+
|
|
24
|
+
return cleaned or "/"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _clean_optional_string(value: Any) -> str | None:
|
|
28
|
+
if not isinstance(value, str):
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
cleaned = value.strip()
|
|
32
|
+
return cleaned or None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
36
|
+
class RouteDescriptor:
|
|
37
|
+
"""
|
|
38
|
+
One stable application endpoint.
|
|
39
|
+
|
|
40
|
+
A FastAPI route supporting several methods becomes one descriptor per
|
|
41
|
+
method, giving every endpoint a deterministic path/method identity.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
path: str
|
|
45
|
+
method: str
|
|
46
|
+
name: str
|
|
47
|
+
operation_id: str | None = None
|
|
48
|
+
tags: tuple[str, ...] = ()
|
|
49
|
+
include_in_schema: bool = True
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
path = _normalise_path(self.path)
|
|
53
|
+
method = self.method.strip().upper()
|
|
54
|
+
name = self.name.strip()
|
|
55
|
+
|
|
56
|
+
if not method:
|
|
57
|
+
raise ValueError("endpoint method must be a non-empty string")
|
|
58
|
+
|
|
59
|
+
if not name:
|
|
60
|
+
raise ValueError("endpoint name must be a non-empty string")
|
|
61
|
+
|
|
62
|
+
tags = tuple(
|
|
63
|
+
dict.fromkeys(
|
|
64
|
+
str(tag).strip()
|
|
65
|
+
for tag in self.tags
|
|
66
|
+
if str(tag).strip()
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
object.__setattr__(self, "path", path)
|
|
71
|
+
object.__setattr__(self, "method", method)
|
|
72
|
+
object.__setattr__(self, "name", name)
|
|
73
|
+
object.__setattr__(
|
|
74
|
+
self,
|
|
75
|
+
"operation_id",
|
|
76
|
+
_clean_optional_string(self.operation_id),
|
|
77
|
+
)
|
|
78
|
+
object.__setattr__(self, "tags", tags)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def key(self) -> tuple[str, str]:
|
|
82
|
+
return self.path, self.method
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def identifier(self) -> str:
|
|
86
|
+
return f"{self.method} {self.path}"
|
|
87
|
+
|
|
88
|
+
def as_dict(self) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"path": self.path,
|
|
91
|
+
"method": self.method,
|
|
92
|
+
"name": self.name,
|
|
93
|
+
"operation_id": self.operation_id,
|
|
94
|
+
"tags": list(self.tags),
|
|
95
|
+
"include_in_schema": self.include_in_schema,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def fingerprint(self) -> str:
|
|
100
|
+
payload = json.dumps(
|
|
101
|
+
self.as_dict(),
|
|
102
|
+
sort_keys=True,
|
|
103
|
+
separators=(",", ":"),
|
|
104
|
+
ensure_ascii=False,
|
|
105
|
+
).encode("utf-8")
|
|
106
|
+
|
|
107
|
+
return hashlib.sha256(payload).hexdigest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class RouteCatalogueDiff:
|
|
112
|
+
added: tuple[RouteDescriptor, ...]
|
|
113
|
+
removed: tuple[RouteDescriptor, ...]
|
|
114
|
+
changed: tuple[
|
|
115
|
+
tuple[RouteDescriptor, RouteDescriptor],
|
|
116
|
+
...,
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def has_changes(self) -> bool:
|
|
121
|
+
return bool(self.added or self.removed or self.changed)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class RouteCatalogue:
|
|
126
|
+
routes: tuple[RouteDescriptor, ...]
|
|
127
|
+
|
|
128
|
+
def __post_init__(self) -> None:
|
|
129
|
+
ordered = tuple(
|
|
130
|
+
sorted(
|
|
131
|
+
self.routes,
|
|
132
|
+
key=lambda route: (
|
|
133
|
+
route.path,
|
|
134
|
+
route.method,
|
|
135
|
+
route.name,
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
keys = [route.key for route in ordered]
|
|
141
|
+
|
|
142
|
+
if len(keys) != len(set(keys)):
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"route catalogue contains duplicate path/method entries"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
object.__setattr__(self, "routes", ordered)
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def empty(cls) -> RouteCatalogue:
|
|
151
|
+
return cls(routes=())
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_app(
|
|
155
|
+
cls,
|
|
156
|
+
app: FastAPI,
|
|
157
|
+
*,
|
|
158
|
+
include_hidden: bool = False,
|
|
159
|
+
) -> RouteCatalogue:
|
|
160
|
+
descriptors: list[RouteDescriptor] = []
|
|
161
|
+
|
|
162
|
+
for route in app.routes:
|
|
163
|
+
if not isinstance(route, APIRoute):
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
if not include_hidden and not route.include_in_schema:
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
methods = sorted(route.methods or ())
|
|
170
|
+
|
|
171
|
+
for method in methods:
|
|
172
|
+
if method in {"HEAD", "OPTIONS"}:
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
operation_id = (
|
|
176
|
+
route.operation_id
|
|
177
|
+
or getattr(route, "unique_id", None)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
descriptors.append(
|
|
181
|
+
RouteDescriptor(
|
|
182
|
+
path=route.path,
|
|
183
|
+
method=method,
|
|
184
|
+
name=route.name,
|
|
185
|
+
operation_id=operation_id,
|
|
186
|
+
tags=tuple(route.tags or ()),
|
|
187
|
+
include_in_schema=route.include_in_schema,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return cls(routes=tuple(descriptors))
|
|
192
|
+
|
|
193
|
+
def __len__(self) -> int:
|
|
194
|
+
return len(self.routes)
|
|
195
|
+
|
|
196
|
+
def __iter__(self):
|
|
197
|
+
return iter(self.routes)
|
|
198
|
+
|
|
199
|
+
def lookup(
|
|
200
|
+
self,
|
|
201
|
+
path: str,
|
|
202
|
+
method: str,
|
|
203
|
+
) -> RouteDescriptor | None:
|
|
204
|
+
key = (
|
|
205
|
+
_normalise_path(path),
|
|
206
|
+
method.strip().upper(),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
for route in self.routes:
|
|
210
|
+
if route.key == key:
|
|
211
|
+
return route
|
|
212
|
+
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def lookup_by_name(
|
|
216
|
+
self,
|
|
217
|
+
name: str,
|
|
218
|
+
) -> tuple[RouteDescriptor, ...]:
|
|
219
|
+
cleaned = name.strip()
|
|
220
|
+
|
|
221
|
+
return tuple(
|
|
222
|
+
route
|
|
223
|
+
for route in self.routes
|
|
224
|
+
if route.name == cleaned
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def lookup_by_operation_id(
|
|
228
|
+
self,
|
|
229
|
+
operation_id: str,
|
|
230
|
+
) -> tuple[RouteDescriptor, ...]:
|
|
231
|
+
cleaned = operation_id.strip()
|
|
232
|
+
|
|
233
|
+
return tuple(
|
|
234
|
+
route
|
|
235
|
+
for route in self.routes
|
|
236
|
+
if route.operation_id == cleaned
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def as_list(self) -> list[dict[str, Any]]:
|
|
240
|
+
return [route.as_dict() for route in self.routes]
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def fingerprint(self) -> str:
|
|
244
|
+
payload = json.dumps(
|
|
245
|
+
self.as_list(),
|
|
246
|
+
sort_keys=True,
|
|
247
|
+
separators=(",", ":"),
|
|
248
|
+
ensure_ascii=False,
|
|
249
|
+
).encode("utf-8")
|
|
250
|
+
|
|
251
|
+
return hashlib.sha256(payload).hexdigest()
|
|
252
|
+
|
|
253
|
+
def diff(
|
|
254
|
+
self,
|
|
255
|
+
previous: RouteCatalogue,
|
|
256
|
+
) -> RouteCatalogueDiff:
|
|
257
|
+
current_by_key = {
|
|
258
|
+
route.key: route
|
|
259
|
+
for route in self.routes
|
|
260
|
+
}
|
|
261
|
+
previous_by_key = {
|
|
262
|
+
route.key: route
|
|
263
|
+
for route in previous.routes
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
added = tuple(
|
|
267
|
+
current_by_key[key]
|
|
268
|
+
for key in sorted(
|
|
269
|
+
current_by_key.keys() - previous_by_key.keys()
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
removed = tuple(
|
|
273
|
+
previous_by_key[key]
|
|
274
|
+
for key in sorted(
|
|
275
|
+
previous_by_key.keys() - current_by_key.keys()
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
changed: list[
|
|
280
|
+
tuple[RouteDescriptor, RouteDescriptor]
|
|
281
|
+
] = []
|
|
282
|
+
|
|
283
|
+
for key in sorted(
|
|
284
|
+
current_by_key.keys() & previous_by_key.keys()
|
|
285
|
+
):
|
|
286
|
+
old = previous_by_key[key]
|
|
287
|
+
new = current_by_key[key]
|
|
288
|
+
|
|
289
|
+
if old != new:
|
|
290
|
+
changed.append((old, new))
|
|
291
|
+
|
|
292
|
+
return RouteCatalogueDiff(
|
|
293
|
+
added=added,
|
|
294
|
+
removed=removed,
|
|
295
|
+
changed=tuple(changed),
|
|
296
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IdentityResolutionError(ValueError):
|
|
10
|
+
"""Raised when an identity helper is configured incorrectly."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_attribute_path(
|
|
14
|
+
value: Any,
|
|
15
|
+
attribute_path: str,
|
|
16
|
+
) -> Any:
|
|
17
|
+
current = value
|
|
18
|
+
|
|
19
|
+
for segment in attribute_path.split("."):
|
|
20
|
+
cleaned = segment.strip()
|
|
21
|
+
|
|
22
|
+
if not cleaned:
|
|
23
|
+
raise IdentityResolutionError(
|
|
24
|
+
"attribute_path contains an empty segment"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if current is None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
if isinstance(current, dict):
|
|
31
|
+
current = current.get(cleaned)
|
|
32
|
+
else:
|
|
33
|
+
current = getattr(current, cleaned, None)
|
|
34
|
+
|
|
35
|
+
return current
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def identify_from_state(
|
|
39
|
+
attribute_path: str,
|
|
40
|
+
) -> Callable[[Request], Any]:
|
|
41
|
+
"""
|
|
42
|
+
Build an identity resolver from request.state.
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
identify_from_state("user.id")
|
|
46
|
+
identify_from_state("external_user_id")
|
|
47
|
+
"""
|
|
48
|
+
if not isinstance(attribute_path, str) or not attribute_path.strip():
|
|
49
|
+
raise IdentityResolutionError(
|
|
50
|
+
"attribute_path must be a non-empty string"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
cleaned_path = attribute_path.strip()
|
|
54
|
+
|
|
55
|
+
def resolver(request: Request) -> Any:
|
|
56
|
+
return _resolve_attribute_path(
|
|
57
|
+
request.state,
|
|
58
|
+
cleaned_path,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return resolver
|