fastapi-ext-pkg 0.5.0__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.5.0 → fastapi_ext_pkg-0.6.0}/PKG-INFO +3 -1
  2. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/PKG-INFO +3 -1
  3. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/SOURCES.txt +8 -0
  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.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/security/auth.py +38 -16
  14. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/security/auth_async.py +17 -14
  15. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/pyproject.toml +4 -1
  16. fastapi_ext_pkg-0.5.0/fastapi_ext_pkg.egg-info/requires.txt +0 -2
  17. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/README.md +0 -0
  18. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/dependency_links.txt +0 -0
  19. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_ext_pkg.egg-info/top_level.txt +0 -0
  20. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/__init__.py +0 -0
  21. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/databases/__init__.py +0 -0
  22. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/databases/redis.py +0 -0
  23. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/__init__.py +0 -0
  24. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/remove_cloud_trace_headers.py +0 -0
  25. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/middlewares/remove_path.py +0 -0
  26. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/orm/__init__.py +0 -0
  27. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/orm/sqlalchemy.py +0 -0
  28. {fastapi_ext_pkg-0.5.0 → fastapi_ext_pkg-0.6.0}/fastapi_extras/security/__init__.py +0 -0
  29. {fastapi_ext_pkg-0.5.0 → 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.5.0
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.5.0
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
@@ -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
@@ -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
@@ -14,6 +14,8 @@ from redis.exceptions import ConnectionError as RedisConnectionError
14
14
  from redis.exceptions import RedisError
15
15
  from typing_extensions import Annotated
16
16
 
17
+ from fastapi_extras.security.auth import SERVICE_UNAVAILABLE, parse_response
18
+
17
19
  logger = logging.getLogger(__name__)
18
20
  ssl_context = httpx.create_ssl_context()
19
21
 
@@ -155,23 +157,24 @@ def remote_authorization_async(
155
157
  try:
156
158
  response = await http_client.post(str(url), json=info, **request_kwargs)
157
159
  except httpx.HTTPError as http_error:
158
- logger.error(http_error)
159
- raise UNAUTHORIZED
160
- else:
161
- authorized = response.status_code == status.HTTP_200_OK
162
- context = response.json()
160
+ logger.error(
161
+ "Authorization request failed: %s: %s", type(http_error).__name__, http_error
162
+ )
163
+ raise SERVICE_UNAVAILABLE
163
164
 
164
- try:
165
- value = json.dumps({"authorized": authorized, "context": context})
166
- await cache.set(key, value, ex=cache_ttl)
167
- except RedisError as cache_error:
168
- logger.warning("Cache set failed, proceeding without cache: %s", cache_error)
165
+ authorized, context = parse_response(response)
169
166
 
170
- if not authorized:
171
- raise UNAUTHORIZED
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
172
 
173
- request.scope["authorizer"] = context
174
- return request.scope["authorizer"]
173
+ if not authorized:
174
+ raise UNAUTHORIZED
175
+
176
+ request.scope["authorizer"] = context
177
+ return request.scope["authorizer"]
175
178
 
176
179
  authorizer.aclose = aclose
177
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.5.0"
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