csrd-context 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.
@@ -0,0 +1,217 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ #uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ #poetry.lock
109
+ #poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ #pdm.lock
116
+ #pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ #pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # SageMath parsed files
135
+ *.sage.py
136
+
137
+ # Environments
138
+ .env
139
+ .envrc
140
+ .venv
141
+ /env/
142
+ /venv/
143
+ ENV/
144
+ env.bak/
145
+ venv.bak/
146
+
147
+ # Spyder project settings
148
+ .spyderproject
149
+ .spyproject
150
+
151
+ # Rope project settings
152
+ .ropeproject
153
+
154
+ # mkdocs documentation
155
+ /site
156
+
157
+ # mypy
158
+ .mypy_cache/
159
+ .dmypy.json
160
+ dmypy.json
161
+
162
+ # Pyre type checker
163
+ .pyre/
164
+
165
+ # pytype static type analyzer
166
+ .pytype/
167
+
168
+ # Cython debug symbols
169
+ cython_debug/
170
+
171
+ # PyCharm
172
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
173
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
174
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
175
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
176
+ #.idea/
177
+
178
+ # Abstra
179
+ # Abstra is an AI-powered process automation framework.
180
+ # Ignore directories containing user credentials, local state, and settings.
181
+ # Learn more at https://abstra.io/docs
182
+ .abstra/
183
+
184
+ # Visual Studio Code
185
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
186
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
187
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
188
+ # you could uncomment the following to ignore the entire vscode folder
189
+ # .vscode/
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+
209
+ *.db
210
+
211
+
212
+ # Import linter cache
213
+ .import_linter_cache/
214
+
215
+ # IDE
216
+ .idea/
217
+ .idea/*
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: csrd-context
3
+ Version: 0.1.0
4
+ Summary: Request context, header utilities, and logging middleware for FastAPI
5
+ Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
+ Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/context
7
+ Project-URL: Changelog, https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md
8
+ License: MIT
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: csrd-models
11
+ Requires-Dist: fastapi<1,>=0.115
12
+ Requires-Dist: starlette>=0.36
@@ -0,0 +1,25 @@
1
+ # csrd-context
2
+
3
+ Request context, header utilities, and logging middleware for FastAPI.
4
+
5
+ **Package**: `csrd.context` · **Import**: `from csrd.context import ...`
6
+
7
+ ## What's included
8
+
9
+ - Framework-agnostic context variable system (headers, path params, query params, API version)
10
+ - `RequestContextMiddleware` — captures request headers into context (raw ASGI, streaming-safe)
11
+ - `HTTPLoggingMiddleware` — structured HTTP request/response logging (raw ASGI, streaming-safe)
12
+ - Platform context variables (`user_info_context`, `hit_id_context`, `app_id_context`)
13
+ - `get_headers()`, `get_hit_id()`, `get_app_id()` — accessors for the current request context
14
+
15
+ ## Installation
16
+
17
+ This package is part of the `fastapi-common` monorepo. Install via git:
18
+
19
+ ```bash
20
+ uv pip install "csrd-context @ git+ssh://git@github.com/csrd-api/fastapi-common.git#subdirectory=packages/context"
21
+ ```
22
+
23
+ ## Dependencies
24
+
25
+ - `csrd-models` (Tier 1 — no other `csrd.*` sibling dependencies)
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "csrd-context"
3
+ version = "0.1.0"
4
+ description = "Request context, header utilities, and logging middleware for FastAPI"
5
+ license = { text = "MIT" }
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "starlette>=0.36",
9
+ "fastapi>=0.115,<1",
10
+ "csrd-models",
11
+ ]
12
+
13
+ [tool.uv.sources]
14
+ csrd-models = { workspace = true }
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/csrd-api/fastapi-common"
18
+ Documentation = "https://github.com/csrd-api/fastapi-common/tree/main/packages/context"
19
+ Changelog = "https://github.com/csrd-api/fastapi-common/blob/main/CHANGELOG.md"
20
+
21
+ [build-system]
22
+ requires = ["hatchling"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ packages = ["src/csrd"]
@@ -0,0 +1,51 @@
1
+ from ._contextvars import (
2
+ configure_headers_context_provider,
3
+ get_api_version,
4
+ get_app_id,
5
+ get_headers,
6
+ get_hit_id,
7
+ get_path_params,
8
+ get_query_params,
9
+ reset_api_version_context,
10
+ reset_global_configuration,
11
+ reset_headers_context,
12
+ reset_path_params,
13
+ reset_query_params,
14
+ set_api_version_context,
15
+ set_headers_context,
16
+ set_path_params,
17
+ set_query_params,
18
+ )
19
+ from ._models import PathValue
20
+ from .middleware import HTTPLoggingMiddleware, RequestContextMiddleware
21
+ from .platform import app_id_context, hit_id_context, user_info_context
22
+
23
+ __all__ = (
24
+ "HTTPLoggingMiddleware",
25
+ # Models
26
+ "PathValue",
27
+ # Middleware
28
+ "RequestContextMiddleware",
29
+ "app_id_context",
30
+ # Context accessors
31
+ "configure_headers_context_provider",
32
+ "get_api_version",
33
+ "get_app_id",
34
+ "get_headers",
35
+ "get_hit_id",
36
+ "get_path_params",
37
+ "get_query_params",
38
+ "hit_id_context",
39
+ "reset_api_version_context",
40
+ "reset_global_configuration",
41
+ "reset_headers_context",
42
+ "reset_path_params",
43
+ "reset_query_params",
44
+ "set_api_version_context",
45
+ # Context setters
46
+ "set_headers_context",
47
+ "set_path_params",
48
+ "set_query_params",
49
+ # Platform contextvars
50
+ "user_info_context",
51
+ )
@@ -0,0 +1,6 @@
1
+ """Header name constants used by context infrastructure."""
2
+
3
+ APP_ID_HEADER_NAME = "x-client-app-id"
4
+ HIT_ID_HEADER_NAME = "x-client-hit-id"
5
+
6
+ __all__ = ("APP_ID_HEADER_NAME", "HIT_ID_HEADER_NAME")
@@ -0,0 +1,184 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from contextvars import ContextVar, Token
4
+ from typing import Any
5
+
6
+ from ._constants import APP_ID_HEADER_NAME, HIT_ID_HEADER_NAME
7
+ from ._models import PathValue
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # Request-scoped context variables and accessors.
12
+ #
13
+ # The ``configure_*`` functions below set module-level state and must be called
14
+ # once during application startup, before any requests are served.
15
+
16
+ _PATH_CONTEXT_KEY = "path_context"
17
+ _QUERY_CONTEXT_KEY = "query_context"
18
+ _API_VERSION_CONTEXT_KEY = "api_version_context"
19
+
20
+ _path_context: ContextVar[PathValue | None] = ContextVar(_PATH_CONTEXT_KEY, default=None)
21
+ _query_context: ContextVar[PathValue | None] = ContextVar(_QUERY_CONTEXT_KEY, default=None)
22
+ _api_version_context: ContextVar[str | None] = ContextVar(_API_VERSION_CONTEXT_KEY, default=None)
23
+
24
+ _headers_getter: Callable[[], Any] | None = None
25
+ _headers_setter: Callable[[Any], Any] | None = None
26
+ _headers_resetter: Callable[[Any], None] | None = None
27
+ _unconfigured_headers_warned = False
28
+
29
+
30
+ def reset_global_configuration() -> None:
31
+ """Reset all module-level configuration to defaults.
32
+
33
+ Intended for test teardown to prevent cross-test contamination.
34
+ """
35
+ global _headers_getter, _headers_setter, _headers_resetter
36
+ global _unconfigured_headers_warned
37
+ _headers_getter = None
38
+ _headers_setter = None
39
+ _headers_resetter = None
40
+ _unconfigured_headers_warned = False
41
+
42
+
43
+ def configure_headers_context_provider(
44
+ *,
45
+ get_headers: Callable[[], Any],
46
+ set_headers: Callable[[Any], Any],
47
+ reset_headers: Callable[[Any], None],
48
+ ) -> None:
49
+ """Configure framework-provided header context accessors.
50
+
51
+ Must be called during application startup, before serving requests.
52
+ """
53
+
54
+ global _headers_getter, _headers_setter, _headers_resetter
55
+ if _headers_getter is not None:
56
+ logger.warning("Overwriting previously configured headers_context_provider")
57
+ _headers_getter = get_headers
58
+ _headers_setter = set_headers
59
+ _headers_resetter = reset_headers
60
+
61
+
62
+ def set_headers_context(headers: Any) -> Any:
63
+ """Set current request headers in the configured framework context."""
64
+ if _headers_setter is None:
65
+ raise RuntimeError(
66
+ "Headers context not configured. "
67
+ "Call configure_headers_context_provider() "
68
+ "before using the context system."
69
+ )
70
+ return _headers_setter(headers)
71
+
72
+
73
+ def reset_headers_context(token: Any) -> None:
74
+ """Reset current request headers from the configured framework context."""
75
+ if token is None:
76
+ return
77
+ if _headers_resetter is None:
78
+ raise RuntimeError(
79
+ "Headers context not configured. "
80
+ "Call configure_headers_context_provider() "
81
+ "before using the context system."
82
+ )
83
+ _headers_resetter(token)
84
+
85
+
86
+ def set_path_params(path_params: PathValue) -> Token[PathValue | None]:
87
+ """Store path parameters for the current async context; returns a token for reset."""
88
+ return _path_context.set(path_params)
89
+
90
+
91
+ def reset_path_params(token: Token[PathValue | None]) -> None:
92
+ """Restore path parameters to their previous value."""
93
+ _path_context.reset(token)
94
+
95
+
96
+ def set_query_params(query_params: PathValue) -> Token[PathValue | None]:
97
+ """Store query parameters for the current async context; returns a token for reset."""
98
+ return _query_context.set(query_params)
99
+
100
+
101
+ def reset_query_params(token: Token[PathValue | None]) -> None:
102
+ """Restore query parameters to their previous value."""
103
+ _query_context.reset(token)
104
+
105
+
106
+ def set_api_version_context(version: str | None) -> Token[str | None]:
107
+ """Store the resolved API version for the current async context."""
108
+ return _api_version_context.set(version)
109
+
110
+
111
+ def reset_api_version_context(token: Token[str | None]) -> None:
112
+ """Restore the API version to its previous value."""
113
+ _api_version_context.reset(token)
114
+
115
+
116
+ def get_path_params() -> PathValue:
117
+ """Return request path parameters captured in the current context."""
118
+ params = _path_context.get()
119
+ if params is None:
120
+ return PathValue()
121
+ return params
122
+
123
+
124
+ def get_query_params() -> PathValue:
125
+ """Return request query parameters captured in the current context."""
126
+ params = _query_context.get()
127
+ if params is None:
128
+ return PathValue()
129
+ return params
130
+
131
+
132
+ def get_api_version() -> str | None:
133
+ """Return resolved API version for the current request context."""
134
+ return _api_version_context.get()
135
+
136
+
137
+ def get_headers() -> Any:
138
+ """Return current request headers captured during dispatch."""
139
+ global _unconfigured_headers_warned
140
+ if _headers_getter is None:
141
+ if not _unconfigured_headers_warned:
142
+ _unconfigured_headers_warned = True
143
+ logger.warning(
144
+ "Headers context provider not configured. "
145
+ "get_headers(), get_app_id(), and get_hit_id() will return empty values. "
146
+ "Call configure_headers_context_provider() during startup."
147
+ )
148
+ return {}
149
+ headers = _headers_getter()
150
+ if headers is None:
151
+ return {}
152
+ return headers
153
+
154
+
155
+ def get_app_id() -> str | None:
156
+ """Return the current request app-id header value."""
157
+ val = get_headers().get(APP_ID_HEADER_NAME, None)
158
+ return str(val) if val is not None else None
159
+
160
+
161
+ def get_hit_id() -> str | None:
162
+ """Return the current request hit-id header value."""
163
+ val = get_headers().get(HIT_ID_HEADER_NAME, None)
164
+ return str(val) if val is not None else None
165
+
166
+
167
+ __all__ = (
168
+ "configure_headers_context_provider",
169
+ "get_api_version",
170
+ "get_app_id",
171
+ "get_headers",
172
+ "get_hit_id",
173
+ "get_path_params",
174
+ "get_query_params",
175
+ "reset_api_version_context",
176
+ "reset_global_configuration",
177
+ "reset_headers_context",
178
+ "reset_path_params",
179
+ "reset_query_params",
180
+ "set_api_version_context",
181
+ "set_headers_context",
182
+ "set_path_params",
183
+ "set_query_params",
184
+ )
@@ -0,0 +1,19 @@
1
+ """FastAPI-specific context variable for request headers."""
2
+
3
+ from contextvars import ContextVar
4
+
5
+ from starlette.datastructures import Headers
6
+
7
+ HEADERS_KEY = "request_headers"
8
+
9
+ _EMPTY_HEADERS = Headers()
10
+
11
+ headers_context: ContextVar[Headers] = ContextVar(HEADERS_KEY, default=_EMPTY_HEADERS)
12
+
13
+
14
+ def get_headers() -> Headers:
15
+ """Return request headers stored in the current context."""
16
+ return headers_context.get()
17
+
18
+
19
+ __all__ = ("HEADERS_KEY", "get_headers", "headers_context")
@@ -0,0 +1,34 @@
1
+ from typing import Any
2
+
3
+
4
+ class PathValue(dict[str, Any]):
5
+ """Dictionary wrapper that also supports dot-notation for key access."""
6
+
7
+ def __getattr__(self, item: str) -> Any:
8
+ """Return dict values via dot-notation, raising AttributeError for missing keys."""
9
+ if item.startswith("__"):
10
+ raise AttributeError(item)
11
+ try:
12
+ return self[item]
13
+ except KeyError:
14
+ raise AttributeError(f"'{type(self).__name__}' has no key '{item}'") from None
15
+
16
+ def __setattr__(self, key: str, value: Any) -> None:
17
+ """Map attribute assignment to dictionary item assignment."""
18
+ if key.startswith("__"):
19
+ super().__setattr__(key, value)
20
+ return
21
+ self[key] = value
22
+
23
+ def __delattr__(self, item: str) -> None:
24
+ """Map attribute deletion to dictionary item deletion."""
25
+ if item.startswith("__"):
26
+ super().__delattr__(item)
27
+ return
28
+ try:
29
+ del self[item]
30
+ except KeyError as exc:
31
+ raise AttributeError(item) from exc
32
+
33
+
34
+ __all__ = ("PathValue",)
@@ -0,0 +1,4 @@
1
+ from ._logging import REQUEST_SCOPE_KEY, HTTPLoggingMiddleware, RequestScope
2
+ from ._request import RequestContextMiddleware
3
+
4
+ __all__ = ("REQUEST_SCOPE_KEY", "HTTPLoggingMiddleware", "RequestContextMiddleware", "RequestScope")
@@ -0,0 +1,121 @@
1
+ """HTTP logging middleware for FastAPI applications.
2
+
3
+ Implemented as a raw ASGI middleware (no ``BaseHTTPMiddleware``) so that
4
+ ``StreamingResponse`` and SSE endpoints are not buffered.
5
+ """
6
+
7
+ import logging
8
+ import time
9
+ import uuid
10
+ from collections import defaultdict
11
+ from http import HTTPStatus
12
+ from typing import Any, TypedDict
13
+
14
+ from starlette.requests import Request
15
+ from starlette.routing import Match
16
+ from starlette.types import ASGIApp, Receive, Scope, Send
17
+
18
+ from csrd.models.claims import UserClaims
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ REQUEST_SCOPE_KEY = "__DS__"
23
+
24
+
25
+ class RequestScope(TypedDict, total=False):
26
+ hit_id: str
27
+ app_id: str
28
+ user_info: UserClaims | None
29
+
30
+
31
+ class HTTPLoggingMiddleware:
32
+ """Raw ASGI middleware to log HTTP request details including timing,
33
+ user context, and response information.
34
+
35
+ Unlike ``BaseHTTPMiddleware``, this does **not** buffer the response
36
+ body, so ``StreamingResponse`` and SSE endpoints work correctly.
37
+ """
38
+
39
+ def __init__(self, app: ASGIApp) -> None:
40
+ self.app = app
41
+
42
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
43
+ if scope["type"] != "http":
44
+ await self.app(scope, receive, send)
45
+ return
46
+
47
+ request = Request(scope)
48
+ scope[REQUEST_SCOPE_KEY] = RequestScope()
49
+
50
+ elapsed = -time.perf_counter()
51
+
52
+ query_params: dict[str, list[str]] = defaultdict(list)
53
+ for key, value in request.query_params.multi_items():
54
+ query_params[key].append(value)
55
+
56
+ extras: dict[str, Any] = {
57
+ "method": request.method,
58
+ "uri": request.url.path,
59
+ "uri_mapping": self._get_route_path(request),
60
+ "query_params": query_params,
61
+ "hit_id": request.headers.get("x-client-hit-id") or str(uuid.uuid4()),
62
+ "app_id": request.headers.get("x-client-app-id", "unknown"),
63
+ }
64
+
65
+ logger.info("http.request.start", extra=extras)
66
+
67
+ # Track response status via a wrapper around send
68
+ status_holder: dict[str, int] = {"status": 0}
69
+
70
+ async def send_wrapper(message: Any) -> None:
71
+ if message["type"] == "http.response.start":
72
+ status_holder["status"] = message["status"]
73
+ await send(message)
74
+
75
+ exc_info = None
76
+ level = logging.INFO
77
+ try:
78
+ await self.app(scope, receive, send_wrapper)
79
+ status = status_holder["status"]
80
+ level = self._get_log_level(status)
81
+ except Exception as exc:
82
+ status = getattr(exc, "status_code", 500)
83
+ extras["error"] = exc.__cause__
84
+ exc_info = exc
85
+ level = logging.ERROR
86
+ raise
87
+ finally:
88
+ ds_scope = scope.get(REQUEST_SCOPE_KEY) or {}
89
+ extras.update(
90
+ hit_id=ds_scope.get("hit_id", "unknown"),
91
+ app_id=ds_scope.get("app_id", "unknown"),
92
+ )
93
+ if user_info := ds_scope.get("user_info"):
94
+ extras.update(
95
+ user_id=user_info.sub,
96
+ user_email=user_info.user_name,
97
+ )
98
+ elapsed += time.perf_counter()
99
+ extras["elapsed_millis"] = int(elapsed * 1000)
100
+ extras["status"] = status
101
+ logger.log(level, "http.request.complete", exc_info=exc_info, extra=extras)
102
+
103
+ @staticmethod
104
+ def _get_log_level(status: int) -> int:
105
+ http_status = HTTPStatus(status)
106
+ if http_status.is_informational or http_status.is_success or http_status.is_redirection:
107
+ return logging.INFO
108
+ if http_status.is_client_error:
109
+ return logging.WARNING
110
+ return logging.ERROR
111
+
112
+ @staticmethod
113
+ def _get_route_path(request: Request) -> str:
114
+ for route in request.app.routes:
115
+ match, _ = route.matches(request.scope)
116
+ if match == Match.FULL:
117
+ return str(route.path)
118
+ return request.url.path
119
+
120
+
121
+ __all__ = ("REQUEST_SCOPE_KEY", "HTTPLoggingMiddleware", "RequestScope")
@@ -0,0 +1,51 @@
1
+ """Request context middleware for setting up headers context.
2
+
3
+ Implemented as a raw ASGI middleware (no ``BaseHTTPMiddleware``) so that
4
+ ``StreamingResponse`` and SSE endpoints are not buffered.
5
+ """
6
+
7
+ from starlette.requests import Request
8
+ from starlette.types import ASGIApp, Receive, Scope, Send
9
+
10
+ from .._contextvars import (
11
+ configure_headers_context_provider,
12
+ reset_headers_context,
13
+ set_headers_context,
14
+ )
15
+ from .._fastapi_headers import headers_context
16
+
17
+
18
+ def _setup_fastapi_headers_provider() -> None:
19
+ """Wire the FastAPI headers ContextVar as the headers provider."""
20
+ configure_headers_context_provider(
21
+ get_headers=headers_context.get,
22
+ set_headers=headers_context.set,
23
+ reset_headers=headers_context.reset,
24
+ )
25
+
26
+
27
+ class RequestContextMiddleware:
28
+ """Raw ASGI middleware that captures request headers into context variables.
29
+
30
+ Unlike ``BaseHTTPMiddleware``, this does **not** buffer the response body,
31
+ so ``StreamingResponse`` and SSE endpoints work correctly.
32
+ """
33
+
34
+ def __init__(self, app: ASGIApp, **kwargs: object) -> None:
35
+ self.app = app
36
+ _setup_fastapi_headers_provider()
37
+
38
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
39
+ if scope["type"] != "http":
40
+ await self.app(scope, receive, send)
41
+ return
42
+
43
+ request = Request(scope)
44
+ token = set_headers_context(request.headers)
45
+ try:
46
+ await self.app(scope, receive, send)
47
+ finally:
48
+ reset_headers_context(token)
49
+
50
+
51
+ __all__ = ("RequestContextMiddleware",)
@@ -0,0 +1,21 @@
1
+ """Platform-level context variables for user info, hit-id, and app-id."""
2
+
3
+ from contextvars import ContextVar
4
+ from typing import Any
5
+
6
+ __all__ = (
7
+ "APP_ID_KEY",
8
+ "HIT_ID_KEY",
9
+ "USER_INFO_KEY",
10
+ "app_id_context",
11
+ "hit_id_context",
12
+ "user_info_context",
13
+ )
14
+
15
+ USER_INFO_KEY = "user_info"
16
+ HIT_ID_KEY = "hit_id"
17
+ APP_ID_KEY = "app_id"
18
+
19
+ user_info_context: ContextVar[Any | None] = ContextVar(USER_INFO_KEY, default=None)
20
+ hit_id_context: ContextVar[str] = ContextVar(HIT_ID_KEY, default="unknown")
21
+ app_id_context: ContextVar[str] = ContextVar(APP_ID_KEY, default="unknown")
File without changes
@@ -0,0 +1,115 @@
1
+ """Tests for csrd.context contextvars system."""
2
+
3
+ import pytest
4
+
5
+ from csrd.context._contextvars import (
6
+ configure_headers_context_provider,
7
+ get_api_version,
8
+ get_app_id,
9
+ get_headers,
10
+ get_hit_id,
11
+ get_path_params,
12
+ get_query_params,
13
+ reset_api_version_context,
14
+ reset_global_configuration,
15
+ reset_headers_context,
16
+ reset_path_params,
17
+ reset_query_params,
18
+ set_api_version_context,
19
+ set_headers_context,
20
+ set_path_params,
21
+ set_query_params,
22
+ )
23
+ from csrd.context._models import PathValue
24
+
25
+
26
+ class TestPathParamsContext:
27
+ def test_set_and_get(self):
28
+ token = set_path_params(PathValue({"id": "42"}))
29
+ try:
30
+ params = get_path_params()
31
+ assert params["id"] == "42"
32
+ assert params.id == "42"
33
+ finally:
34
+ reset_path_params(token)
35
+
36
+ def test_default_empty(self):
37
+ params = get_path_params()
38
+ assert len(params) == 0
39
+
40
+ def test_reset(self):
41
+ token = set_path_params(PathValue({"x": "y"}))
42
+ reset_path_params(token)
43
+ assert len(get_path_params()) == 0
44
+
45
+
46
+ class TestQueryParamsContext:
47
+ def test_set_and_get(self):
48
+ token = set_query_params(PathValue({"page": "2"}))
49
+ try:
50
+ params = get_query_params()
51
+ assert params["page"] == "2"
52
+ finally:
53
+ reset_query_params(token)
54
+
55
+ def test_default_empty(self):
56
+ params = get_query_params()
57
+ assert len(params) == 0
58
+
59
+
60
+ class TestApiVersionContext:
61
+ def test_set_and_get(self):
62
+ token = set_api_version_context("2025-06-20")
63
+ try:
64
+ assert get_api_version() == "2025-06-20"
65
+ finally:
66
+ reset_api_version_context(token)
67
+
68
+ def test_default_none(self):
69
+ assert get_api_version() is None
70
+
71
+
72
+ class TestHeadersContext:
73
+ def setup_method(self):
74
+ reset_global_configuration()
75
+
76
+ def teardown_method(self):
77
+ reset_global_configuration()
78
+
79
+ def test_unconfigured_returns_empty_dict(self):
80
+ headers = get_headers()
81
+ assert headers == {}
82
+
83
+ def test_configure_and_use(self):
84
+ from contextvars import ContextVar
85
+
86
+ cv: ContextVar[dict] = ContextVar("test_headers")
87
+ cv.set({})
88
+
89
+ configure_headers_context_provider(
90
+ get_headers=cv.get,
91
+ set_headers=cv.set,
92
+ reset_headers=cv.reset,
93
+ )
94
+ token = set_headers_context({"x-client-app-id": "myapp", "x-client-hit-id": "hit123"})
95
+ try:
96
+ headers = get_headers()
97
+ assert headers["x-client-app-id"] == "myapp"
98
+ assert get_app_id() == "myapp"
99
+ assert get_hit_id() == "hit123"
100
+ finally:
101
+ reset_headers_context(token)
102
+
103
+ def test_set_headers_before_configure_raises(self):
104
+ with pytest.raises(RuntimeError, match="not configured"):
105
+ set_headers_context({"key": "val"})
106
+
107
+ def test_reset_headers_none_token_is_noop(self):
108
+ # Should not raise
109
+ reset_headers_context(None)
110
+
111
+ def test_get_app_id_unconfigured(self):
112
+ assert get_app_id() is None
113
+
114
+ def test_get_hit_id_unconfigured(self):
115
+ assert get_hit_id() is None
@@ -0,0 +1,52 @@
1
+ """Tests for RequestContextMiddleware and HTTPLoggingMiddleware."""
2
+
3
+ import pytest
4
+ from fastapi import FastAPI
5
+ from httpx import ASGITransport, AsyncClient
6
+
7
+ from csrd.context import RequestContextMiddleware, get_headers
8
+ from csrd.context._contextvars import reset_global_configuration
9
+
10
+
11
+ @pytest.fixture
12
+ def app():
13
+ app = FastAPI()
14
+
15
+ @app.get("/test")
16
+ async def test_endpoint():
17
+ headers = get_headers()
18
+ return {
19
+ "x-custom": headers.get("x-custom", "missing"),
20
+ "has_headers": len(dict(headers)) > 0,
21
+ }
22
+
23
+ app.add_middleware(RequestContextMiddleware)
24
+ return app
25
+
26
+
27
+ @pytest.fixture
28
+ def anyio_backend():
29
+ return "asyncio"
30
+
31
+
32
+ class TestRequestContextMiddleware:
33
+ @pytest.mark.asyncio
34
+ async def test_headers_captured(self, app):
35
+ reset_global_configuration()
36
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
37
+ resp = await client.get("/test", headers={"x-custom": "hello"})
38
+ assert resp.status_code == 200
39
+ data = resp.json()
40
+ assert data["x-custom"] == "hello"
41
+ assert data["has_headers"] is True
42
+ reset_global_configuration()
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_headers_isolated_between_requests(self, app):
46
+ reset_global_configuration()
47
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
48
+ resp1 = await client.get("/test", headers={"x-custom": "first"})
49
+ resp2 = await client.get("/test", headers={"x-custom": "second"})
50
+ assert resp1.json()["x-custom"] == "first"
51
+ assert resp2.json()["x-custom"] == "second"
52
+ reset_global_configuration()
@@ -0,0 +1,45 @@
1
+ """Tests for PathValue dict wrapper."""
2
+
3
+ import pytest
4
+
5
+ from csrd.context._models import PathValue
6
+
7
+
8
+ class TestPathValue:
9
+ def test_dict_access(self):
10
+ pv = PathValue({"user_id": "123", "org": "acme"})
11
+ assert pv["user_id"] == "123"
12
+
13
+ def test_dot_notation(self):
14
+ pv = PathValue({"user_id": "123"})
15
+ assert pv.user_id == "123"
16
+
17
+ def test_dot_notation_missing_raises_attribute_error(self):
18
+ pv = PathValue()
19
+ with pytest.raises(AttributeError, match="no key"):
20
+ _ = pv.missing_key
21
+
22
+ def test_set_via_dot_notation(self):
23
+ pv = PathValue()
24
+ pv.name = "Alice"
25
+ assert pv["name"] == "Alice"
26
+
27
+ def test_del_via_dot_notation(self):
28
+ pv = PathValue({"key": "val"})
29
+ del pv.key
30
+ assert "key" not in pv
31
+
32
+ def test_del_missing_raises(self):
33
+ pv = PathValue()
34
+ with pytest.raises(AttributeError):
35
+ del pv.nope
36
+
37
+ def test_dunder_attrs_use_regular_path(self):
38
+ pv = PathValue()
39
+ with pytest.raises(AttributeError):
40
+ _ = pv.__nonexistent__
41
+
42
+ def test_empty_defaults(self):
43
+ pv = PathValue()
44
+ assert len(pv) == 0
45
+ assert dict(pv) == {}
@@ -0,0 +1,30 @@
1
+ """Tests for platform-level context variables."""
2
+
3
+ from csrd.context.platform import app_id_context, hit_id_context, user_info_context
4
+ from csrd.models.claims import UserClaims
5
+
6
+
7
+ class TestPlatformContextVars:
8
+ def test_user_info_default_none(self):
9
+ assert user_info_context.get() is None
10
+
11
+ def test_user_info_set_and_get(self):
12
+ claims = UserClaims(sub="user1", user_name="alice")
13
+ token = user_info_context.set(claims)
14
+ try:
15
+ assert user_info_context.get().sub == "user1"
16
+ finally:
17
+ user_info_context.reset(token)
18
+
19
+ def test_hit_id_default(self):
20
+ assert hit_id_context.get() == "unknown"
21
+
22
+ def test_app_id_default(self):
23
+ assert app_id_context.get() == "unknown"
24
+
25
+ def test_hit_id_set(self):
26
+ token = hit_id_context.set("req-abc-123")
27
+ try:
28
+ assert hit_id_context.get() == "req-abc-123"
29
+ finally:
30
+ hit_id_context.reset(token)