fastapi-ext-pkg 0.4.4__tar.gz → 0.6.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.
Files changed (29) hide show
  1. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/PKG-INFO +3 -1
  2. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/PKG-INFO +3 -1
  3. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/SOURCES.txt +10 -1
  4. fastapi_ext_pkg-0.6.0/fastapi_ext_pkg.egg-info/requires.txt +5 -0
  5. fastapi_ext_pkg-0.6.0/fastapi_extras/apm/__init__.py +19 -0
  6. fastapi_ext_pkg-0.6.0/fastapi_extras/apm/config.py +66 -0
  7. fastapi_ext_pkg-0.6.0/fastapi_extras/apm/constants.py +46 -0
  8. fastapi_ext_pkg-0.6.0/fastapi_extras/apm/decorators.py +127 -0
  9. fastapi_ext_pkg-0.6.0/fastapi_extras/apm/labels.py +20 -0
  10. fastapi_ext_pkg-0.6.0/fastapi_extras/apm/masking.py +87 -0
  11. fastapi_ext_pkg-0.6.0/fastapi_extras/apm/middleware.py +105 -0
  12. fastapi_ext_pkg-0.6.0/fastapi_extras/apm/processors.py +80 -0
  13. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/databases/redis.py +13 -2
  14. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/security/auth.py +38 -16
  15. fastapi_ext_pkg-0.6.0/fastapi_extras/security/auth_async.py +180 -0
  16. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/pyproject.toml +4 -1
  17. fastapi_ext_pkg-0.4.4/fastapi_ext_pkg.egg-info/requires.txt +0 -2
  18. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/README.md +0 -0
  19. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/dependency_links.txt +0 -0
  20. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/top_level.txt +0 -0
  21. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/__init__.py +0 -0
  22. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/databases/__init__.py +0 -0
  23. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/__init__.py +0 -0
  24. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/remove_cloud_trace_headers.py +0 -0
  25. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/remove_path.py +0 -0
  26. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/orm/__init__.py +0 -0
  27. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/orm/sqlalchemy.py +0 -0
  28. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/fastapi_extras/security/__init__.py +0 -0
  29. {fastapi_ext_pkg-0.4.4 → fastapi_ext_pkg-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-ext-pkg
3
- Version: 0.4.4
3
+ Version: 0.6.0
4
4
  Summary: A well-tailored utility set for FastAPI.
5
5
  Author-email: Dotz Developers <devs-dotz@dotz.com>
6
6
  Project-URL: Repository, https://github.com/DotzInc/fastapi-ext-pkg
@@ -20,6 +20,8 @@ Requires-Python: >=3.8
20
20
  Description-Content-Type: text/markdown
21
21
  Requires-Dist: fastapi
22
22
  Requires-Dist: httpx
23
+ Provides-Extra: apm
24
+ Requires-Dist: elastic-apm<7,>=6.20; extra == "apm"
23
25
 
24
26
  # FastAPI Extension Package
25
27
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-ext-pkg
3
- Version: 0.4.4
3
+ Version: 0.6.0
4
4
  Summary: A well-tailored utility set for FastAPI.
5
5
  Author-email: Dotz Developers <devs-dotz@dotz.com>
6
6
  Project-URL: Repository, https://github.com/DotzInc/fastapi-ext-pkg
@@ -20,6 +20,8 @@ Requires-Python: >=3.8
20
20
  Description-Content-Type: text/markdown
21
21
  Requires-Dist: fastapi
22
22
  Requires-Dist: httpx
23
+ Provides-Extra: apm
24
+ Requires-Dist: elastic-apm<7,>=6.20; extra == "apm"
23
25
 
24
26
  # FastAPI Extension Package
25
27
 
@@ -6,6 +6,14 @@ fastapi_ext_pkg.egg-info/dependency_links.txt
6
6
  fastapi_ext_pkg.egg-info/requires.txt
7
7
  fastapi_ext_pkg.egg-info/top_level.txt
8
8
  fastapi_extras/__init__.py
9
+ fastapi_extras/apm/__init__.py
10
+ fastapi_extras/apm/config.py
11
+ fastapi_extras/apm/constants.py
12
+ fastapi_extras/apm/decorators.py
13
+ fastapi_extras/apm/labels.py
14
+ fastapi_extras/apm/masking.py
15
+ fastapi_extras/apm/middleware.py
16
+ fastapi_extras/apm/processors.py
9
17
  fastapi_extras/databases/__init__.py
10
18
  fastapi_extras/databases/redis.py
11
19
  fastapi_extras/middlewares/__init__.py
@@ -14,4 +22,5 @@ fastapi_extras/middlewares/remove_path.py
14
22
  fastapi_extras/orm/__init__.py
15
23
  fastapi_extras/orm/sqlalchemy.py
16
24
  fastapi_extras/security/__init__.py
17
- fastapi_extras/security/auth.py
25
+ fastapi_extras/security/auth.py
26
+ fastapi_extras/security/auth_async.py
@@ -0,0 +1,5 @@
1
+ fastapi
2
+ httpx
3
+
4
+ [apm]
5
+ elastic-apm<7,>=6.20
@@ -0,0 +1,19 @@
1
+ from fastapi_extras.apm.config import build_apm_config
2
+ from fastapi_extras.apm.constants import DEFAULT_SANITIZE_FIELD_NAMES
3
+ from fastapi_extras.apm.decorators import capture_http_request
4
+ from fastapi_extras.apm.labels import label_transaction
5
+ from fastapi_extras.apm.masking import mask_sensitive_data, normalize_span_value
6
+ from fastapi_extras.apm.middleware import ElasticAPMMiddleware
7
+ from fastapi_extras.apm.processors import ALL_PROCESSORS, PROCESSORS
8
+
9
+ __all__ = [
10
+ "ALL_PROCESSORS",
11
+ "DEFAULT_SANITIZE_FIELD_NAMES",
12
+ "PROCESSORS",
13
+ "ElasticAPMMiddleware",
14
+ "build_apm_config",
15
+ "capture_http_request",
16
+ "label_transaction",
17
+ "mask_sensitive_data",
18
+ "normalize_span_value",
19
+ ]
@@ -0,0 +1,66 @@
1
+ from typing import Any, Dict, Iterable, List, Optional, Union
2
+
3
+ import fastapi
4
+
5
+ from fastapi_extras.apm.constants import (
6
+ DEFAULT_CAPTURE_BODY,
7
+ DEFAULT_CLOUD_PROVIDER,
8
+ DEFAULT_LOG_LEVEL,
9
+ DEFAULT_SANITIZE_FIELD_NAMES,
10
+ )
11
+ from fastapi_extras.apm.processors import ALL_PROCESSORS
12
+
13
+ FieldNamesInput = Union[str, Iterable[str]]
14
+
15
+
16
+ def parse_field_names(value: FieldNamesInput) -> List[str]:
17
+ items = value.split(",") if isinstance(value, str) else value
18
+ return [item.strip() for item in items if item.strip()]
19
+
20
+
21
+ def unique(items: Iterable[str]) -> List[str]:
22
+ return list(dict.fromkeys(items))
23
+
24
+
25
+ def has_credentials(server_url: Optional[str], secret_token: Optional[str]) -> bool:
26
+ return bool((server_url or "").strip() and (secret_token or "").strip())
27
+
28
+
29
+ def build_apm_config(
30
+ service_name: str,
31
+ *,
32
+ server_url: Optional[str] = "",
33
+ secret_token: Optional[str] = "",
34
+ environment: str = "",
35
+ enabled: Optional[bool] = None,
36
+ log_level: str = DEFAULT_LOG_LEVEL,
37
+ capture_body: str = DEFAULT_CAPTURE_BODY,
38
+ sanitize_field_names: Optional[FieldNamesInput] = None,
39
+ extra_sanitize_field_names: FieldNamesInput = (),
40
+ extra_processors: Iterable[str] = (),
41
+ **overrides: Any,
42
+ ) -> Dict[str, Any]:
43
+ base_field_names = (
44
+ DEFAULT_SANITIZE_FIELD_NAMES
45
+ if sanitize_field_names is None
46
+ else parse_field_names(sanitize_field_names)
47
+ )
48
+ config: Dict[str, Any] = {
49
+ "ENABLED": has_credentials(server_url, secret_token) if enabled is None else enabled,
50
+ "SERVICE_NAME": service_name,
51
+ "SECRET_TOKEN": secret_token or "",
52
+ "SERVER_URL": server_url or "",
53
+ "ENVIRONMENT": environment,
54
+ "LOG_LEVEL": log_level,
55
+ "CLOUD_PROVIDER": DEFAULT_CLOUD_PROVIDER,
56
+ "FRAMEWORK_NAME": "fastapi",
57
+ "FRAMEWORK_VERSION": fastapi.__version__,
58
+ "CAPTURE_HEADERS": True,
59
+ "CAPTURE_BODY": capture_body,
60
+ "SANITIZE_FIELD_NAMES": unique(
61
+ [*base_field_names, *parse_field_names(extra_sanitize_field_names)]
62
+ ),
63
+ "PROCESSORS": (*ALL_PROCESSORS, *extra_processors),
64
+ }
65
+ config.update({key.upper(): value for key, value in overrides.items()})
66
+ return config
@@ -0,0 +1,46 @@
1
+ EXTERNAL = "external"
2
+ UNKNOWN = "UNKNOWN"
3
+ UNKNOWN_STATUS_CODE = 999
4
+ MAX_FIELD_LENGTH = 500
5
+ MAX_RESPONSE_BODY_BYTES = 64 * 1024
6
+ TRUNCATED_SUFFIX = "… [TRUNCATED]"
7
+ UNREADABLE_RESPONSE = "<could not decode response>"
8
+ RESPONSE_BODY_TOO_LARGE = "<response body too large>"
9
+ RESPONSE_BODY_CONTEXT_KEY = "response_body"
10
+ JSON_MEDIA_TYPE = "application/json"
11
+ JSON_MEDIA_TYPE_SUFFIX = "+json"
12
+ IDENTITY_ENCODING = "identity"
13
+ DEFAULT_SANITIZE_FIELD_NAMES = (
14
+ "password",
15
+ "passwd",
16
+ "pwd",
17
+ "secret",
18
+ "*key",
19
+ "*token*",
20
+ "*session*",
21
+ "*credit*",
22
+ "*card*",
23
+ "*auth*",
24
+ "*principal*",
25
+ "set-cookie",
26
+ "pin",
27
+ "otp",
28
+ "*document*",
29
+ "*cpf*",
30
+ "*cnpj*",
31
+ "*email*",
32
+ "*phone*",
33
+ "*mobile*",
34
+ "*birth*",
35
+ "mother_name",
36
+ "rg",
37
+ "*account_number*",
38
+ "agency",
39
+ "pix_key",
40
+ "*cvv*",
41
+ "*salary*",
42
+ "*income*",
43
+ )
44
+ DEFAULT_LOG_LEVEL = "INFO"
45
+ DEFAULT_CAPTURE_BODY = "off"
46
+ DEFAULT_CLOUD_PROVIDER = "gcp"
@@ -0,0 +1,127 @@
1
+ import functools
2
+ import inspect
3
+ from typing import Any, Awaitable, Callable, Dict, Mapping, Optional, Tuple, TypeVar
4
+ from urllib.parse import urlparse
5
+
6
+ import elasticapm
7
+ from elasticapm.conf.constants import MASK
8
+
9
+ from fastapi_extras.apm.constants import EXTERNAL, UNKNOWN, UNKNOWN_STATUS_CODE, UNREADABLE_RESPONSE
10
+ from fastapi_extras.apm.labels import to_label_value
11
+ from fastapi_extras.apm.masking import (
12
+ FieldNames,
13
+ get_sanitize_field_names,
14
+ is_sensitive_key,
15
+ mask_sensitive_data,
16
+ normalize_span_value,
17
+ )
18
+
19
+ ReturnType = TypeVar("ReturnType")
20
+
21
+ ERROR_BODY_ATTRIBUTES = ("errors", "content", "detail")
22
+
23
+
24
+ def get_span_name(method: str, url: str) -> str:
25
+ parsed = urlparse(url)
26
+ path = parsed.path
27
+ if parsed.query:
28
+ path = f"{path}?{parsed.query}"
29
+ return f"{method.upper()} {path}"
30
+
31
+
32
+ def is_outgoing_request(value: Any) -> bool:
33
+ return isinstance(getattr(value, "method", None), str) and isinstance(
34
+ getattr(value, "url", None), str
35
+ )
36
+
37
+
38
+ def find_outgoing_request(args: Tuple[Any, ...], kwargs: Mapping[str, Any]) -> Any:
39
+ candidate = kwargs.get("request")
40
+ if is_outgoing_request(candidate):
41
+ return candidate
42
+ return next((arg for arg in args if is_outgoing_request(arg)), None)
43
+
44
+
45
+ async def read_response(response: Any) -> Tuple[Optional[int], Any]:
46
+ status_code = getattr(response, "status", None)
47
+ if not isinstance(status_code, int):
48
+ status_code = getattr(response, "status_code", None)
49
+ if not isinstance(status_code, int):
50
+ return None, None
51
+ try:
52
+ text = response.text
53
+ body = text() if callable(text) else text
54
+ if inspect.isawaitable(body):
55
+ body = await body
56
+ except Exception:
57
+ body = UNREADABLE_RESPONSE
58
+ return status_code, body
59
+
60
+
61
+ def read_error(error: Exception) -> Tuple[int, Any]:
62
+ status_code = getattr(error, "status_code", None)
63
+ if not isinstance(status_code, int):
64
+ return UNKNOWN_STATUS_CODE, repr(error)
65
+ body = {}
66
+ for attribute in ERROR_BODY_ATTRIBUTES:
67
+ value = getattr(error, attribute, None)
68
+ if value:
69
+ body[attribute] = value
70
+ return status_code, body
71
+
72
+
73
+ def build_span_labels(
74
+ status_code: Optional[int],
75
+ response_body: Any,
76
+ payload: Any,
77
+ extra_labels: Optional[Mapping[str, Any]] = None,
78
+ field_names: Optional[FieldNames] = None,
79
+ ) -> Dict[str, Any]:
80
+ names = get_sanitize_field_names() if field_names is None else field_names
81
+ labels: Dict[str, Any] = {
82
+ "status_code": status_code,
83
+ "response_body": normalize_span_value(response_body),
84
+ }
85
+ if payload:
86
+ labels["payload"] = normalize_span_value(payload)
87
+ if extra_labels:
88
+ labels.update(extra_labels)
89
+ return {
90
+ key: MASK
91
+ if is_sensitive_key(key, names)
92
+ else to_label_value(mask_sensitive_data(value, names))
93
+ for key, value in labels.items()
94
+ }
95
+
96
+
97
+ def capture_http_request(
98
+ func: Callable[..., Awaitable[ReturnType]],
99
+ ) -> Callable[..., Awaitable[ReturnType]]:
100
+ @functools.wraps(func)
101
+ async def wrapper(*args: Any, **kwargs: Any) -> ReturnType:
102
+ request = find_outgoing_request(args, kwargs)
103
+ method = getattr(request, "method", UNKNOWN)
104
+ url = getattr(request, "url", UNKNOWN)
105
+ payload = getattr(request, "payload", None)
106
+ status_code: Optional[int] = None
107
+ response_body: Any = None
108
+
109
+ async with elasticapm.async_capture_span(
110
+ get_span_name(method, url), span_type=EXTERNAL
111
+ ) as span:
112
+ try:
113
+ response = await func(*args, **kwargs)
114
+ status_code, response_body = await read_response(response)
115
+ return response
116
+ except Exception as error:
117
+ status_code, response_body = read_error(error)
118
+ raise
119
+ finally:
120
+ if span:
121
+ span.label(
122
+ **build_span_labels(
123
+ status_code, response_body, payload, kwargs.get("labels")
124
+ )
125
+ )
126
+
127
+ return wrapper
@@ -0,0 +1,20 @@
1
+ import json
2
+ from typing import Any, Iterable, Mapping
3
+
4
+ import elasticapm
5
+
6
+ from fastapi_extras.apm.masking import get_sanitize_field_names, sanitize, truncate
7
+
8
+
9
+ def to_label_value(value: Any) -> Any:
10
+ if isinstance(value, (dict, list)):
11
+ return json.dumps(value, ensure_ascii=False, default=str)
12
+ return value
13
+
14
+
15
+ def label_transaction(data: Mapping[str, Any], keys: Iterable[str]) -> None:
16
+ labels = {key: data[key] for key in keys if data.get(key) is not None}
17
+ if not labels:
18
+ return
19
+ safe_labels = truncate(sanitize(labels, get_sanitize_field_names()))
20
+ elasticapm.label(**{key: to_label_value(value) for key, value in safe_labels.items()})
@@ -0,0 +1,87 @@
1
+ import base64
2
+ import json
3
+ import logging
4
+ from typing import Any, Optional, Pattern, Sequence
5
+
6
+ import elasticapm
7
+ from elasticapm.conf.constants import BASE_SANITIZE_FIELD_NAMES, MASK
8
+ from pydantic import BaseModel
9
+
10
+ from fastapi_extras.apm.constants import MAX_FIELD_LENGTH, TRUNCATED_SUFFIX
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ FieldNames = Sequence[Pattern]
15
+
16
+
17
+ def get_sanitize_field_names() -> FieldNames:
18
+ client = elasticapm.get_client()
19
+ if client is None:
20
+ return BASE_SANITIZE_FIELD_NAMES
21
+ return client.config.sanitize_field_names
22
+
23
+
24
+ def is_sensitive_key(key: Any, field_names: FieldNames) -> bool:
25
+ if not isinstance(key, str):
26
+ return False
27
+ normalized = key.strip().lower()
28
+ return any(pattern.match(normalized) for pattern in field_names)
29
+
30
+
31
+ def sanitize(data: Any, field_names: FieldNames) -> Any:
32
+ if isinstance(data, dict):
33
+ return {
34
+ key: MASK if is_sensitive_key(key, field_names) else sanitize(value, field_names)
35
+ for key, value in data.items()
36
+ }
37
+ if isinstance(data, (list, tuple)):
38
+ return [sanitize(item, field_names) for item in data]
39
+ return data
40
+
41
+
42
+ def truncate(data: Any, max_length: int = MAX_FIELD_LENGTH) -> Any:
43
+ if isinstance(data, dict):
44
+ return {key: truncate(value, max_length) for key, value in data.items()}
45
+ if isinstance(data, (list, tuple)):
46
+ return [truncate(item, max_length) for item in data]
47
+ if data is None or isinstance(data, (bool, int, float)):
48
+ return data
49
+ text = data if isinstance(data, str) else str(data)
50
+ if len(text) > max_length:
51
+ return text[:max_length] + TRUNCATED_SUFFIX
52
+ return text
53
+
54
+
55
+ def normalize_span_value(value: Any) -> Any:
56
+ if isinstance(value, BaseModel):
57
+ dump = getattr(value, "model_dump", None) or value.dict
58
+ return dump()
59
+ if isinstance(value, (bytes, bytearray)):
60
+ try:
61
+ return bytes(value).decode("utf-8")
62
+ except UnicodeDecodeError:
63
+ return base64.b64encode(bytes(value)).decode("ascii")
64
+ return value
65
+
66
+
67
+ def parse_json(value: str) -> Any:
68
+ try:
69
+ return json.loads(value)
70
+ except ValueError:
71
+ return value
72
+
73
+
74
+ def mask_sensitive_data(
75
+ data: Any,
76
+ field_names: Optional[FieldNames] = None,
77
+ max_length: int = MAX_FIELD_LENGTH,
78
+ ) -> Any:
79
+ names = get_sanitize_field_names() if field_names is None else field_names
80
+ try:
81
+ value = normalize_span_value(data)
82
+ if isinstance(value, str):
83
+ value = parse_json(value)
84
+ return truncate(sanitize(value, names), max_length)
85
+ except Exception:
86
+ logger.exception("Could not mask APM data")
87
+ return MASK
@@ -0,0 +1,105 @@
1
+ import functools
2
+ from typing import Any, List, Optional
3
+
4
+ import elasticapm
5
+ from elasticapm.base import Client
6
+ from elasticapm.contrib.starlette import ElasticAPM
7
+ from starlette.datastructures import Headers
8
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
9
+
10
+ from fastapi_extras.apm.constants import (
11
+ IDENTITY_ENCODING,
12
+ JSON_MEDIA_TYPE,
13
+ JSON_MEDIA_TYPE_SUFFIX,
14
+ MAX_RESPONSE_BODY_BYTES,
15
+ RESPONSE_BODY_CONTEXT_KEY,
16
+ RESPONSE_BODY_TOO_LARGE,
17
+ )
18
+ from fastapi_extras.apm.masking import mask_sensitive_data
19
+
20
+
21
+ def is_json(headers: Headers) -> bool:
22
+ media_type = headers.get("content-type", "").split(";")[0].strip().lower()
23
+ return media_type == JSON_MEDIA_TYPE or media_type.endswith(JSON_MEDIA_TYPE_SUFFIX)
24
+
25
+
26
+ def is_encoded(headers: Headers) -> bool:
27
+ encoding = headers.get("content-encoding", IDENTITY_ENCODING).strip().lower()
28
+ return encoding not in ("", IDENTITY_ENCODING)
29
+
30
+
31
+ class ResponseBodyRecorder:
32
+ def __init__(self, max_bytes: int) -> None:
33
+ self.max_bytes = max_bytes
34
+ self.chunks: List[bytes] = []
35
+ self.size = 0
36
+ self.readable = False
37
+ self.too_large = False
38
+
39
+ def start(self, message: Message) -> None:
40
+ headers = Headers(raw=message.get("headers", []))
41
+ content_length = headers.get("content-length", "")
42
+ self.readable = is_json(headers) and not is_encoded(headers)
43
+ self.too_large = content_length.isdigit() and int(content_length) > self.max_bytes
44
+
45
+ def feed(self, message: Message) -> bool:
46
+ if self.readable and not self.too_large:
47
+ self._append(message.get("body", b""))
48
+ return not message.get("more_body", False)
49
+
50
+ def _append(self, chunk: bytes) -> None:
51
+ self.size += len(chunk)
52
+ if self.size > self.max_bytes:
53
+ self.too_large = True
54
+ self.chunks.clear()
55
+ elif chunk:
56
+ self.chunks.append(chunk)
57
+
58
+
59
+ class ElasticAPMMiddleware(ElasticAPM):
60
+ def __init__(
61
+ self,
62
+ app: ASGIApp,
63
+ client: Optional[Client] = None,
64
+ capture_response_body: bool = True,
65
+ max_response_body_bytes: int = MAX_RESPONSE_BODY_BYTES,
66
+ **kwargs: Any,
67
+ ) -> None:
68
+ super().__init__(app, client, **kwargs)
69
+ self.capture_response_body = capture_response_body
70
+ self.max_response_body_bytes = max_response_body_bytes
71
+
72
+ def _record_response_body(self, recorder: ResponseBodyRecorder) -> None:
73
+ if not recorder.readable:
74
+ return
75
+ if recorder.too_large:
76
+ body = RESPONSE_BODY_TOO_LARGE
77
+ else:
78
+ body = mask_sensitive_data(
79
+ b"".join(recorder.chunks), self.client.config.sanitize_field_names
80
+ )
81
+ elasticapm.set_custom_context({RESPONSE_BODY_CONTEXT_KEY: body})
82
+
83
+ def _should_record(self, scope: Scope) -> bool:
84
+ return (
85
+ scope["type"] == "http"
86
+ and self.capture_response_body
87
+ and not self.client.should_ignore_url(scope["path"])
88
+ )
89
+
90
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
91
+ if not self._should_record(scope):
92
+ await super().__call__(scope, receive, send)
93
+ return
94
+
95
+ recorder = ResponseBodyRecorder(self.max_response_body_bytes)
96
+
97
+ @functools.wraps(send)
98
+ async def recording_send(message: Message) -> None:
99
+ if message.get("type") == "http.response.start":
100
+ recorder.start(message)
101
+ elif message.get("type") == "http.response.body" and recorder.feed(message):
102
+ self._record_response_body(recorder)
103
+ await send(message)
104
+
105
+ await super().__call__(scope, receive, recording_send)
@@ -0,0 +1,80 @@
1
+ import json
2
+ from typing import Any, Dict, Optional
3
+
4
+ from elasticapm.conf.constants import ERROR, MASK, SPAN, TRANSACTION
5
+ from elasticapm.processors import for_events
6
+
7
+ from fastapi_extras.apm.masking import FieldNames, is_sensitive_key, sanitize
8
+
9
+ PROCESSORS = (
10
+ "fastapi_extras.apm.processors.sanitize_http_request_json_body",
11
+ "fastapi_extras.apm.processors.sanitize_custom_context",
12
+ "fastapi_extras.apm.processors.sanitize_labels",
13
+ )
14
+ AGENT_PROCESSORS = (
15
+ "elasticapm.processors.sanitize_stacktrace_locals",
16
+ "elasticapm.processors.sanitize_http_request_cookies",
17
+ "elasticapm.processors.sanitize_http_response_cookies",
18
+ "elasticapm.processors.sanitize_http_headers",
19
+ "elasticapm.processors.sanitize_http_wsgi_env",
20
+ "elasticapm.processors.sanitize_http_request_body",
21
+ )
22
+ ALL_PROCESSORS = (*PROCESSORS, *AGENT_PROCESSORS)
23
+
24
+
25
+ def load_json_structure(value: Any) -> Optional[Any]:
26
+ if isinstance(value, (dict, list)):
27
+ return value
28
+ if not isinstance(value, (str, bytes, bytearray)):
29
+ return None
30
+ try:
31
+ parsed = json.loads(value)
32
+ except ValueError:
33
+ return None
34
+ return parsed if isinstance(parsed, (dict, list)) else None
35
+
36
+
37
+ def sanitize_label(key: str, value: Any, field_names: FieldNames) -> Any:
38
+ if is_sensitive_key(key, field_names):
39
+ return MASK
40
+ if not isinstance(value, str):
41
+ return value
42
+ parsed = load_json_structure(value)
43
+ if parsed is None:
44
+ return value
45
+ return json.dumps(sanitize(parsed, field_names), ensure_ascii=False)
46
+
47
+
48
+ @for_events(ERROR, TRANSACTION)
49
+ def sanitize_http_request_json_body(client: Any, event: Dict[str, Any]) -> Dict[str, Any]:
50
+ try:
51
+ body = event["context"]["request"]["body"]
52
+ except (KeyError, TypeError):
53
+ return event
54
+ parsed = load_json_structure(body)
55
+ if parsed is None:
56
+ return event
57
+ event["context"]["request"]["body"] = json.dumps(
58
+ sanitize(parsed, client.config.sanitize_field_names)
59
+ )
60
+ return event
61
+
62
+
63
+ @for_events(ERROR, TRANSACTION)
64
+ def sanitize_custom_context(client: Any, event: Dict[str, Any]) -> Dict[str, Any]:
65
+ custom = (event.get("context") or {}).get("custom")
66
+ if isinstance(custom, dict):
67
+ event["context"]["custom"] = sanitize(custom, client.config.sanitize_field_names)
68
+ return event
69
+
70
+
71
+ @for_events(SPAN, TRANSACTION)
72
+ def sanitize_labels(client: Any, event: Dict[str, Any]) -> Dict[str, Any]:
73
+ tags = (event.get("context") or {}).get("tags")
74
+ if not isinstance(tags, dict):
75
+ return event
76
+ field_names = client.config.sanitize_field_names
77
+ event["context"]["tags"] = {
78
+ key: sanitize_label(key, value, field_names) for key, value in tags.items()
79
+ }
80
+ return event
@@ -15,8 +15,19 @@ class Redis(redis.Redis):
15
15
 
16
16
 
17
17
  class RedisManager:
18
- def __init__(self, url: Union[RedisDsn, str]):
19
- self.pool = redis.ConnectionPool.from_url(str(url))
18
+ def __init__(
19
+ self,
20
+ url: Union[RedisDsn, str],
21
+ socket_timeout: float = 2.0,
22
+ socket_connect_timeout: float = 2.0,
23
+ health_check_interval: int = 30,
24
+ ):
25
+ self.pool = redis.ConnectionPool.from_url(
26
+ str(url),
27
+ socket_timeout=socket_timeout,
28
+ socket_connect_timeout=socket_connect_timeout,
29
+ health_check_interval=health_check_interval,
30
+ )
20
31
 
21
32
  async def __call__(self) -> AsyncGenerator[Redis, None]:
22
33
  cli = Redis(connection_pool=self.pool)
@@ -2,7 +2,7 @@ import hashlib
2
2
  import json
3
3
  import logging
4
4
  import typing
5
- from typing import Any, AsyncGenerator, Awaitable, Callable, Optional, Protocol, Union
5
+ from typing import Any, AsyncGenerator, Awaitable, Callable, Optional, Protocol, Tuple, Union
6
6
 
7
7
  import httpx
8
8
  from fastapi import Depends, HTTPException, Request, status
@@ -14,6 +14,12 @@ logger = logging.getLogger(__name__)
14
14
  ssl_context = httpx.create_ssl_context()
15
15
 
16
16
  UNAUTHORIZED = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
17
+ SERVICE_UNAVAILABLE = HTTPException(
18
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Authorization service unavailable"
19
+ )
20
+ DENIED_STATUS_CODES = frozenset(
21
+ {status.HTTP_400_BAD_REQUEST, status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN}
22
+ )
17
23
  DEFAULT_SCHEME = APIKeyHeader(name="Authorization")
18
24
  DEFAULT_CACHE_TTL = 60 # seconds
19
25
 
@@ -38,6 +44,21 @@ def keygen(seed: str, prefix: str = "") -> str:
38
44
  return f"{prefix}{hash}"
39
45
 
40
46
 
47
+ def parse_response(response: httpx.Response) -> Tuple[bool, Any]:
48
+ if response.status_code in DENIED_STATUS_CODES:
49
+ return False, None
50
+
51
+ if response.status_code != status.HTTP_200_OK:
52
+ logger.error(f"Authorization service returned unexpected status {response.status_code}")
53
+ raise SERVICE_UNAVAILABLE
54
+
55
+ try:
56
+ return True, response.json()
57
+ except ValueError:
58
+ logger.error(f"Authorization service returned a non-JSON body: {response.text}")
59
+ raise SERVICE_UNAVAILABLE
60
+
61
+
41
62
  def remote_authorization(
42
63
  url: Union[str, AnyHttpUrl],
43
64
  *,
@@ -60,7 +81,7 @@ def remote_authorization(
60
81
  try:
61
82
  cached = await cache.get(key)
62
83
  except Exception as cache_error:
63
- logger.warning("Cache get failed, proceeding without cache: %s", cache_error)
84
+ logger.warning(f"Cache get failed, proceeding without cache: {cache_error}")
64
85
 
65
86
  if cached:
66
87
  cached = json.loads(cached)
@@ -84,22 +105,23 @@ def remote_authorization(
84
105
  try:
85
106
  response = await client.post(str(url), json=info, **kwargs)
86
107
  except httpx.HTTPError as http_error:
87
- logger.error(http_error)
88
- raise UNAUTHORIZED
89
- else:
90
- authorized = response.status_code == status.HTTP_200_OK
91
- context = response.json()
108
+ logger.error(
109
+ f"Authorization request failed: {type(http_error).__name__}: {http_error}"
110
+ )
111
+ raise SERVICE_UNAVAILABLE
112
+
113
+ authorized, context = parse_response(response)
92
114
 
93
- try:
94
- value = json.dumps({"authorized": authorized, "context": context})
95
- await cache.set(key, value, cache_ttl)
96
- except Exception as cache_error:
97
- logger.warning("Cache set failed, proceeding without cache: %s", cache_error)
115
+ try:
116
+ value = json.dumps({"authorized": authorized, "context": context})
117
+ await cache.set(key, value, cache_ttl)
118
+ except Exception as cache_error:
119
+ logger.warning(f"Cache set failed, proceeding without cache: {cache_error}")
98
120
 
99
- if not authorized:
100
- raise UNAUTHORIZED
121
+ if not authorized:
122
+ raise UNAUTHORIZED
101
123
 
102
- request.scope["authorizer"] = context
103
- return request.scope["authorizer"]
124
+ request.scope["authorizer"] = context
125
+ return request.scope["authorizer"]
104
126
 
105
127
  return authorizer
@@ -0,0 +1,180 @@
1
+ import hashlib
2
+ import json
3
+ import logging
4
+ from typing import Any, AsyncGenerator, Callable, Optional, Protocol, Union
5
+
6
+ import httpx
7
+ from fastapi import Depends, HTTPException, Request, status
8
+ from fastapi.security import APIKeyCookie, APIKeyHeader, APIKeyQuery
9
+ from pydantic import AnyHttpUrl, RedisDsn
10
+ from redis.asyncio import ConnectionPool, Redis
11
+ from redis.asyncio.retry import Retry
12
+ from redis.backoff import ExponentialBackoff
13
+ from redis.exceptions import ConnectionError as RedisConnectionError
14
+ from redis.exceptions import RedisError
15
+ from typing_extensions import Annotated
16
+
17
+ from fastapi_extras.security.auth import SERVICE_UNAVAILABLE, parse_response
18
+
19
+ logger = logging.getLogger(__name__)
20
+ ssl_context = httpx.create_ssl_context()
21
+
22
+ UNAUTHORIZED = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
23
+ DEFAULT_SCHEME = APIKeyHeader(name="Authorization")
24
+ DEFAULT_CACHE_TTL = 60 # seconds
25
+
26
+ # Redis client bounds: keep a stuck connection from a Redis outage from being
27
+ # reused forever instead of getting discarded and replaced.
28
+ DEFAULT_SOCKET_TIMEOUT = 2.0
29
+ DEFAULT_SOCKET_CONNECT_TIMEOUT = 2.0
30
+ DEFAULT_HEALTH_CHECK_INTERVAL = 30
31
+ DEFAULT_HTTP_TIMEOUT = 5.0
32
+ DEFAULT_HTTP_CONNECT_TIMEOUT = 2.0
33
+
34
+ APIKeyScheme = Union[APIKeyCookie, APIKeyHeader, APIKeyQuery]
35
+
36
+ CacheDependency = Callable[..., Union[Redis, AsyncGenerator[Redis, None]]]
37
+ HTTPClientDependency = Callable[..., httpx.AsyncClient]
38
+
39
+
40
+ def keygen(seed: str, prefix: str = "") -> str:
41
+ hash = hashlib.sha256(seed.encode()).hexdigest()
42
+ return f"{prefix}{hash}"
43
+
44
+
45
+ class AsyncRedisCache:
46
+ """FastAPI dependency yielding a redis.asyncio.Redis bound to a shared
47
+ pool with bounded timeouts + health checks, so stuck connections from a
48
+ Redis outage get discarded instead of reused forever."""
49
+
50
+ def __init__(
51
+ self,
52
+ url: Union[RedisDsn, str],
53
+ *,
54
+ socket_timeout: float = DEFAULT_SOCKET_TIMEOUT,
55
+ socket_connect_timeout: float = DEFAULT_SOCKET_CONNECT_TIMEOUT,
56
+ health_check_interval: int = DEFAULT_HEALTH_CHECK_INTERVAL,
57
+ ) -> None:
58
+ self.pool = ConnectionPool.from_url(
59
+ str(url),
60
+ decode_responses=True,
61
+ socket_timeout=socket_timeout,
62
+ socket_connect_timeout=socket_connect_timeout,
63
+ health_check_interval=health_check_interval,
64
+ retry_on_error=[RedisConnectionError],
65
+ retry=Retry(ExponentialBackoff(cap=1.0, base=0.05), retries=1),
66
+ )
67
+
68
+ async def __call__(self) -> AsyncGenerator[Redis, None]:
69
+ cli = Redis(connection_pool=self.pool)
70
+ try:
71
+ yield cli
72
+ finally:
73
+ await cli.aclose()
74
+
75
+
76
+ class Authorizer(Protocol):
77
+ """Structural type for what remote_authorization_async returns: a FastAPI
78
+ dependency callable that also owns a closeable httpx client."""
79
+
80
+ async def __call__(
81
+ self, request: Request, token: str, cache: Redis, http_client: httpx.AsyncClient
82
+ ) -> Any: ...
83
+ async def aclose(self) -> None: ...
84
+
85
+
86
+ def remote_authorization_async(
87
+ url: Union[str, AnyHttpUrl],
88
+ *,
89
+ scheme: APIKeyScheme = DEFAULT_SCHEME,
90
+ cache_gen: CacheDependency,
91
+ http_client_gen: Optional[HTTPClientDependency] = None,
92
+ cache_ttl: int = DEFAULT_CACHE_TTL,
93
+ http_timeout: float = DEFAULT_HTTP_TIMEOUT,
94
+ http_connect_timeout: float = DEFAULT_HTTP_CONNECT_TIMEOUT,
95
+ **kwargs: Any,
96
+ ) -> Authorizer:
97
+ if not isinstance(scheme, (APIKeyCookie, APIKeyHeader, APIKeyQuery)):
98
+ raise TypeError("Invalid APIKeyScheme")
99
+
100
+ request_timeout = httpx.Timeout(http_timeout, connect=http_connect_timeout)
101
+
102
+ # Only built when the caller has no httpx client of its own to inject.
103
+ _owned_client: Optional[httpx.AsyncClient] = None
104
+
105
+ def _get_owned_client() -> httpx.AsyncClient:
106
+ nonlocal _owned_client
107
+ if _owned_client is None:
108
+ _owned_client = httpx.AsyncClient(verify=ssl_context, timeout=request_timeout)
109
+ return _owned_client
110
+
111
+ async def _default_http_client_gen() -> httpx.AsyncClient:
112
+ return _get_owned_client()
113
+
114
+ async def aclose() -> None:
115
+ nonlocal _owned_client
116
+ if _owned_client is not None:
117
+ await _owned_client.aclose()
118
+ _owned_client = None
119
+
120
+ async def authorizer(
121
+ request: Request,
122
+ token: Annotated[str, Depends(scheme)],
123
+ cache: Annotated[Redis, Depends(cache_gen)],
124
+ http_client: Annotated[
125
+ httpx.AsyncClient, Depends(http_client_gen or _default_http_client_gen)
126
+ ],
127
+ ) -> Any:
128
+ key = keygen(token, prefix="authorizer:")
129
+
130
+ cached = None
131
+ try:
132
+ cached = await cache.get(key)
133
+ except RedisError as cache_error:
134
+ logger.warning("Cache get failed, proceeding without cache: %s", cache_error)
135
+
136
+ if cached:
137
+ cached = json.loads(cached)
138
+
139
+ if cached and isinstance(cached, dict):
140
+ if not cached.get("authorized"):
141
+ raise UNAUTHORIZED
142
+
143
+ request.scope["authorizer"] = cached.get("context")
144
+ return request.scope["authorizer"]
145
+
146
+ info = {
147
+ "method": request.method,
148
+ "path": request.url.path,
149
+ "query_params": dict(request.query_params),
150
+ "headers": dict(request.headers),
151
+ "cookies": request.cookies,
152
+ }
153
+
154
+ # Explicit per-call timeout so it applies even when http_client is an
155
+ # externally-shared client whose own default timeout differs.
156
+ request_kwargs = {**kwargs, "timeout": request_timeout}
157
+ try:
158
+ response = await http_client.post(str(url), json=info, **request_kwargs)
159
+ except httpx.HTTPError as http_error:
160
+ logger.error(
161
+ "Authorization request failed: %s: %s", type(http_error).__name__, http_error
162
+ )
163
+ raise SERVICE_UNAVAILABLE
164
+
165
+ authorized, context = parse_response(response)
166
+
167
+ try:
168
+ value = json.dumps({"authorized": authorized, "context": context})
169
+ await cache.set(key, value, ex=cache_ttl)
170
+ except RedisError as cache_error:
171
+ logger.warning("Cache set failed, proceeding without cache: %s", cache_error)
172
+
173
+ if not authorized:
174
+ raise UNAUTHORIZED
175
+
176
+ request.scope["authorizer"] = context
177
+ return request.scope["authorizer"]
178
+
179
+ authorizer.aclose = aclose
180
+ return authorizer
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fastapi-ext-pkg"
7
- version = "0.4.4"
7
+ version = "0.6.0"
8
8
  description = "A well-tailored utility set for FastAPI."
9
9
  readme = {file = "README.md", content-type = "text/markdown"}
10
10
  authors = [
@@ -27,6 +27,9 @@ classifiers = [
27
27
  requires-python = ">=3.8"
28
28
  dependencies = ["fastapi", "httpx"]
29
29
 
30
+ [project.optional-dependencies]
31
+ apm = ["elastic-apm>=6.20,<7"]
32
+
30
33
  [project.urls]
31
34
  Repository = "https://github.com/DotzInc/fastapi-ext-pkg"
32
35
 
@@ -1,2 +0,0 @@
1
- fastapi
2
- httpx