standard-fastapi 0.2.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.
- standard_fastapi-0.2.0/.gitignore +31 -0
- standard_fastapi-0.2.0/LICENSE +21 -0
- standard_fastapi-0.2.0/PKG-INFO +77 -0
- standard_fastapi-0.2.0/README.md +52 -0
- standard_fastapi-0.2.0/pyproject.toml +42 -0
- standard_fastapi-0.2.0/src/std/fastapi/__init__.py +50 -0
- standard_fastapi-0.2.0/src/std/fastapi/api/__init__.py +8 -0
- standard_fastapi-0.2.0/src/std/fastapi/api/config.py +58 -0
- standard_fastapi-0.2.0/src/std/fastapi/api/lifecycle.py +175 -0
- standard_fastapi-0.2.0/src/std/fastapi/api/service.py +21 -0
- standard_fastapi-0.2.0/src/std/fastapi/api/setup.py +55 -0
- standard_fastapi-0.2.0/src/std/fastapi/auth/__init__.py +21 -0
- standard_fastapi-0.2.0/src/std/fastapi/auth/config.py +53 -0
- standard_fastapi-0.2.0/src/std/fastapi/auth/middleware.py +165 -0
- standard_fastapi-0.2.0/src/std/fastapi/dependencies.py +37 -0
- standard_fastapi-0.2.0/src/std/fastapi/health/__init__.py +6 -0
- standard_fastapi-0.2.0/src/std/fastapi/health/endpoint.py +122 -0
- standard_fastapi-0.2.0/src/std/fastapi/logging/__init__.py +5 -0
- standard_fastapi-0.2.0/src/std/fastapi/logging/middleware.py +70 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Workspace-managed mounts.
|
|
2
|
+
/.agents/
|
|
3
|
+
/.aws/
|
|
4
|
+
/.codex/
|
|
5
|
+
|
|
6
|
+
# Python tooling and generated files.
|
|
7
|
+
/.venv/
|
|
8
|
+
/venv/
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*.egg-info/
|
|
12
|
+
/build/
|
|
13
|
+
/dist/
|
|
14
|
+
/.coverage
|
|
15
|
+
/.coverage.*
|
|
16
|
+
/htmlcov/
|
|
17
|
+
/deprecated/
|
|
18
|
+
/.pytest_cache/
|
|
19
|
+
/.mypy_cache/
|
|
20
|
+
/.ruff_cache/
|
|
21
|
+
/.tox/
|
|
22
|
+
|
|
23
|
+
# Documentation output.
|
|
24
|
+
/.cache/
|
|
25
|
+
/site/
|
|
26
|
+
|
|
27
|
+
# Local secrets and environment files.
|
|
28
|
+
.env
|
|
29
|
+
.env.*
|
|
30
|
+
!.env.example
|
|
31
|
+
.kata.local.toml
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Standard contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: standard-fastapi
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: FastAPI integration for the Standard Python library
|
|
5
|
+
Project-URL: Documentation, https://appunni-m.github.io/python-standard/api/
|
|
6
|
+
Project-URL: Repository, https://github.com/appunni-m/python-standard
|
|
7
|
+
Author: Standard contributors
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: fastapi,pydantic-settings,standard
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: fastapi<1,>=0.115
|
|
23
|
+
Requires-Dist: standard==0.2.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# standard-fastapi
|
|
27
|
+
|
|
28
|
+
FastAPI integration for the [`standard`](https://pypi.org/project/standard/)
|
|
29
|
+
Python library. After publication, install it directly with
|
|
30
|
+
`python -m pip install standard-fastapi` or use the `standard[fastapi]` extra.
|
|
31
|
+
|
|
32
|
+
Define the application's settings model in `core/settings.py`:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from std.config import YamlSettings
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Settings(YamlSettings):
|
|
39
|
+
std_config_file = "settings.yaml"
|
|
40
|
+
service_name: str
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
In `main.py`, create the app and inject that settings instance once:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from fastapi import Depends, FastAPI
|
|
47
|
+
from app.core.settings import Settings
|
|
48
|
+
from std.fastapi import settings_dependency
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_app() -> FastAPI:
|
|
52
|
+
settings = Settings() # Create once in the application composition root.
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
app.state.settings = settings
|
|
55
|
+
settings_dep = settings_dependency(Settings)
|
|
56
|
+
|
|
57
|
+
@app.get("/health")
|
|
58
|
+
async def health(settings: Settings = Depends(settings_dep)) -> dict[str, str]:
|
|
59
|
+
return {"service": settings.service_name}
|
|
60
|
+
|
|
61
|
+
return app
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Create `Settings()` once in `main.py` and assign the instance to
|
|
65
|
+
`app.state.settings`. The dependency returns that injected instance and never
|
|
66
|
+
constructs or reloads settings.
|
|
67
|
+
|
|
68
|
+
The integration includes `APIConfig` and `FastAPISetup` for app construction,
|
|
69
|
+
`LifecycleManager` for injected background services, `AppTokenAuth` and
|
|
70
|
+
`TokenAuthMiddleware`, health-check endpoints, request logging middleware, and
|
|
71
|
+
the Pydantic Settings dependency helper. Core service lifecycle types remain
|
|
72
|
+
available as `std.service` from `standard`.
|
|
73
|
+
|
|
74
|
+
The core library and this package are versioned independently. The `std`
|
|
75
|
+
command and core modules remain in the `standard` distribution. See the
|
|
76
|
+
[API reference](https://appunni-m.github.io/python-standard/api/) for public
|
|
77
|
+
signatures, behavior, and examples.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# standard-fastapi
|
|
2
|
+
|
|
3
|
+
FastAPI integration for the [`standard`](https://pypi.org/project/standard/)
|
|
4
|
+
Python library. After publication, install it directly with
|
|
5
|
+
`python -m pip install standard-fastapi` or use the `standard[fastapi]` extra.
|
|
6
|
+
|
|
7
|
+
Define the application's settings model in `core/settings.py`:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from std.config import YamlSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Settings(YamlSettings):
|
|
14
|
+
std_config_file = "settings.yaml"
|
|
15
|
+
service_name: str
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
In `main.py`, create the app and inject that settings instance once:
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from fastapi import Depends, FastAPI
|
|
22
|
+
from app.core.settings import Settings
|
|
23
|
+
from std.fastapi import settings_dependency
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app() -> FastAPI:
|
|
27
|
+
settings = Settings() # Create once in the application composition root.
|
|
28
|
+
app = FastAPI()
|
|
29
|
+
app.state.settings = settings
|
|
30
|
+
settings_dep = settings_dependency(Settings)
|
|
31
|
+
|
|
32
|
+
@app.get("/health")
|
|
33
|
+
async def health(settings: Settings = Depends(settings_dep)) -> dict[str, str]:
|
|
34
|
+
return {"service": settings.service_name}
|
|
35
|
+
|
|
36
|
+
return app
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Create `Settings()` once in `main.py` and assign the instance to
|
|
40
|
+
`app.state.settings`. The dependency returns that injected instance and never
|
|
41
|
+
constructs or reloads settings.
|
|
42
|
+
|
|
43
|
+
The integration includes `APIConfig` and `FastAPISetup` for app construction,
|
|
44
|
+
`LifecycleManager` for injected background services, `AppTokenAuth` and
|
|
45
|
+
`TokenAuthMiddleware`, health-check endpoints, request logging middleware, and
|
|
46
|
+
the Pydantic Settings dependency helper. Core service lifecycle types remain
|
|
47
|
+
available as `std.service` from `standard`.
|
|
48
|
+
|
|
49
|
+
The core library and this package are versioned independently. The `std`
|
|
50
|
+
command and core modules remain in the `standard` distribution. See the
|
|
51
|
+
[API reference](https://appunni-m.github.io/python-standard/api/) for public
|
|
52
|
+
signatures, behavior, and examples.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2", "hatch-vcs>=0.4,<1"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "standard-fastapi"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "FastAPI integration for the Standard Python library"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Standard contributors" }]
|
|
14
|
+
keywords = ["fastapi", "pydantic-settings", "standard"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
dependencies = ["standard==0.2.0", "fastapi>=0.115,<1"]
|
|
28
|
+
urls = { Documentation = "https://appunni-m.github.io/python-standard/api/", Repository = "https://github.com/appunni-m/python-standard" }
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
only-include = ["src/std/fastapi"]
|
|
32
|
+
sources = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.sdist]
|
|
35
|
+
only-include = ["pyproject.toml", "README.md", "LICENSE", "src/std/fastapi"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.version]
|
|
38
|
+
source = "vcs"
|
|
39
|
+
tag-pattern = "^v(?P<version>\\d+\\.\\d+\\.\\d+(?:(?:a|b|rc)\\d+)?)$"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version.raw-options]
|
|
42
|
+
search_parent_directories = true
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""FastAPI application, authentication, health, lifecycle, and logging tools."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import fastapi as _fastapi # noqa: F401
|
|
5
|
+
except ImportError as error: # pragma: no cover - broken optional installation
|
|
6
|
+
raise ImportError(
|
|
7
|
+
"FastAPI integration requires the FastAPI dependency; install "
|
|
8
|
+
"`standard-fastapi` or `standard[fastapi]`. From a checkout run "
|
|
9
|
+
"`python -m pip install -e . -e packages/standard-fastapi`."
|
|
10
|
+
) from error
|
|
11
|
+
|
|
12
|
+
from std.fastapi.api import APIConfig, APIService, Environment, FastAPISetup, LifecycleManager
|
|
13
|
+
from std.fastapi.auth import (
|
|
14
|
+
AppTokenAuth,
|
|
15
|
+
AppTokenConfig,
|
|
16
|
+
TokenAuthMiddleware,
|
|
17
|
+
add_api_key_security_scheme,
|
|
18
|
+
apply_token_auth_middleware,
|
|
19
|
+
setup_app_token_auth,
|
|
20
|
+
setup_token_auth,
|
|
21
|
+
)
|
|
22
|
+
from std.fastapi.dependencies import settings_dependency
|
|
23
|
+
from std.fastapi.health import (
|
|
24
|
+
HealthCheck,
|
|
25
|
+
HealthCheckFunc,
|
|
26
|
+
HealthResponse,
|
|
27
|
+
create_health_endpoint,
|
|
28
|
+
)
|
|
29
|
+
from std.fastapi.logging import LoggingMiddleware
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"APIConfig",
|
|
33
|
+
"APIService",
|
|
34
|
+
"AppTokenAuth",
|
|
35
|
+
"AppTokenConfig",
|
|
36
|
+
"Environment",
|
|
37
|
+
"FastAPISetup",
|
|
38
|
+
"HealthCheck",
|
|
39
|
+
"HealthCheckFunc",
|
|
40
|
+
"HealthResponse",
|
|
41
|
+
"LifecycleManager",
|
|
42
|
+
"LoggingMiddleware",
|
|
43
|
+
"TokenAuthMiddleware",
|
|
44
|
+
"add_api_key_security_scheme",
|
|
45
|
+
"apply_token_auth_middleware",
|
|
46
|
+
"create_health_endpoint",
|
|
47
|
+
"settings_dependency",
|
|
48
|
+
"setup_app_token_auth",
|
|
49
|
+
"setup_token_auth",
|
|
50
|
+
]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""FastAPI application configuration and lifecycle support."""
|
|
2
|
+
|
|
3
|
+
from std.fastapi.api.config import APIConfig, Environment
|
|
4
|
+
from std.fastapi.api.lifecycle import LifecycleManager
|
|
5
|
+
from std.fastapi.api.service import APIService
|
|
6
|
+
from std.fastapi.api.setup import FastAPISetup
|
|
7
|
+
|
|
8
|
+
__all__ = ["APIConfig", "APIService", "Environment", "FastAPISetup", "LifecycleManager"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Validated server and application metadata for FastAPI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Environment(StrEnum):
|
|
11
|
+
"""Deployment environments supported by the FastAPI setup helper."""
|
|
12
|
+
|
|
13
|
+
DEV = "dev"
|
|
14
|
+
STAGE = "stage"
|
|
15
|
+
PROD = "prod"
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
"""Return the configuration value."""
|
|
19
|
+
return self.value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class APIConfig(BaseModel):
|
|
23
|
+
"""Application metadata, server options, and CORS policy."""
|
|
24
|
+
|
|
25
|
+
model_config = ConfigDict(extra="forbid")
|
|
26
|
+
|
|
27
|
+
enabled: bool = Field(default=True, description="Whether the API is enabled")
|
|
28
|
+
env: Environment = Field(default=Environment.DEV, description="Deployment environment")
|
|
29
|
+
title: str = Field(default="Standard API", min_length=1)
|
|
30
|
+
description: str = Field(default="API service")
|
|
31
|
+
version: str = Field(default="1.0.0", min_length=1)
|
|
32
|
+
host: str = Field(default="0.0.0.0", min_length=1)
|
|
33
|
+
port: int = Field(default=8000, ge=1, le=65535)
|
|
34
|
+
reload: bool = True
|
|
35
|
+
workers: int = Field(default=1, ge=1)
|
|
36
|
+
debug: bool = True
|
|
37
|
+
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
|
38
|
+
cors_methods: list[str] = Field(default_factory=lambda: ["*"])
|
|
39
|
+
cors_headers: list[str] = Field(default_factory=lambda: ["*"])
|
|
40
|
+
cors_credentials: bool = True
|
|
41
|
+
|
|
42
|
+
@field_validator("host")
|
|
43
|
+
@classmethod
|
|
44
|
+
def validate_host(cls, value: str) -> str:
|
|
45
|
+
"""Reject whitespace-only bind hosts."""
|
|
46
|
+
host = value.strip()
|
|
47
|
+
if not host:
|
|
48
|
+
raise ValueError("host cannot be empty")
|
|
49
|
+
return host
|
|
50
|
+
|
|
51
|
+
@field_validator("cors_origins", "cors_methods", "cors_headers")
|
|
52
|
+
@classmethod
|
|
53
|
+
def validate_cors_values(cls, values: list[str]) -> list[str]:
|
|
54
|
+
"""Reject empty CORS entries and normalize surrounding whitespace."""
|
|
55
|
+
normalized = [value.strip() for value in values]
|
|
56
|
+
if any(not value for value in normalized):
|
|
57
|
+
raise ValueError("CORS entries cannot be empty")
|
|
58
|
+
return normalized
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Coordinate injected background services with an ASGI application lifespan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
|
|
8
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
from std.service import BackgroundServiceProtocol, ServiceState
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
SetupHandler = Callable[[], Awaitable[Sequence[BackgroundServiceProtocol]]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LifecycleManager:
|
|
18
|
+
"""Start services in order and stop them in reverse order.
|
|
19
|
+
|
|
20
|
+
A service's stop hook is invoked even when its startup fails, allowing it
|
|
21
|
+
to release resources acquired before the failure. Shutdown attempts every
|
|
22
|
+
cleanup hook and groups errors after all services have been visited.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
services: Sequence[BackgroundServiceProtocol] = (),
|
|
28
|
+
*,
|
|
29
|
+
setup_handler: SetupHandler | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Create a manager for supplied services and an optional async factory."""
|
|
32
|
+
self._services = list(services)
|
|
33
|
+
if len({id(service) for service in self._services}) != len(self._services):
|
|
34
|
+
raise ValueError("the same service instance cannot be registered twice")
|
|
35
|
+
self._setup_handler = setup_handler
|
|
36
|
+
self._active_services: list[BackgroundServiceProtocol] = []
|
|
37
|
+
self._state = ServiceState.IDLE
|
|
38
|
+
self._lock = asyncio.Lock()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def status(self) -> ServiceState:
|
|
42
|
+
"""Return the lifecycle manager's current state."""
|
|
43
|
+
return self._state
|
|
44
|
+
|
|
45
|
+
def add_service(self, service: BackgroundServiceProtocol) -> None:
|
|
46
|
+
"""Register a service before startup."""
|
|
47
|
+
if self._state in (ServiceState.STARTING, ServiceState.RUNNING, ServiceState.STOPPING):
|
|
48
|
+
raise RuntimeError("services cannot be added while the lifecycle manager is active")
|
|
49
|
+
if any(existing is service for existing in self._services):
|
|
50
|
+
raise ValueError("the same service instance cannot be registered twice")
|
|
51
|
+
self._services.append(service)
|
|
52
|
+
|
|
53
|
+
def set_setup_handler(
|
|
54
|
+
self,
|
|
55
|
+
handler: SetupHandler,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Set an async factory for services created at application startup."""
|
|
58
|
+
if self._state in (ServiceState.STARTING, ServiceState.RUNNING, ServiceState.STOPPING):
|
|
59
|
+
raise RuntimeError("the setup handler cannot change while the manager is active")
|
|
60
|
+
self._setup_handler = handler
|
|
61
|
+
|
|
62
|
+
async def start(self) -> None:
|
|
63
|
+
"""Start registered and dynamically created services in order."""
|
|
64
|
+
async with self._lock:
|
|
65
|
+
if self._state is ServiceState.RUNNING:
|
|
66
|
+
return
|
|
67
|
+
if self._state in (ServiceState.STARTING, ServiceState.STOPPING):
|
|
68
|
+
raise RuntimeError(f"cannot start lifecycle manager in state {self._state}")
|
|
69
|
+
|
|
70
|
+
self._state = ServiceState.STARTING
|
|
71
|
+
started: list[BackgroundServiceProtocol] = []
|
|
72
|
+
cleanup_failures: list[BackgroundServiceProtocol] = []
|
|
73
|
+
try:
|
|
74
|
+
dynamic = await self._setup_handler() if self._setup_handler else ()
|
|
75
|
+
services = [*self._services, *dynamic]
|
|
76
|
+
if len({id(service) for service in services}) != len(services):
|
|
77
|
+
raise ValueError("the same service instance cannot be started twice")
|
|
78
|
+
|
|
79
|
+
for service in services:
|
|
80
|
+
try:
|
|
81
|
+
await service.start()
|
|
82
|
+
except BaseException as startup_error:
|
|
83
|
+
cleanup_failures = await self._cleanup_after_start_failure(
|
|
84
|
+
service, started, startup_error
|
|
85
|
+
)
|
|
86
|
+
raise
|
|
87
|
+
started.append(service)
|
|
88
|
+
except BaseException:
|
|
89
|
+
self._active_services = list(reversed(cleanup_failures))
|
|
90
|
+
self._state = ServiceState.FAILED
|
|
91
|
+
raise
|
|
92
|
+
|
|
93
|
+
self._active_services = started
|
|
94
|
+
self._state = ServiceState.RUNNING
|
|
95
|
+
logger.info("Started %d background services", len(started))
|
|
96
|
+
|
|
97
|
+
async def stop(self) -> None:
|
|
98
|
+
"""Stop every active service in reverse order, attempting all cleanups."""
|
|
99
|
+
async with self._lock:
|
|
100
|
+
if self._state in (ServiceState.IDLE, ServiceState.STOPPED):
|
|
101
|
+
return
|
|
102
|
+
if self._state is ServiceState.STOPPING:
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
self._state = ServiceState.STOPPING
|
|
106
|
+
errors: list[Exception] = []
|
|
107
|
+
failed_services: list[BackgroundServiceProtocol] = []
|
|
108
|
+
try:
|
|
109
|
+
for service in reversed(self._active_services):
|
|
110
|
+
try:
|
|
111
|
+
await service.stop()
|
|
112
|
+
except Exception as error:
|
|
113
|
+
errors.append(error)
|
|
114
|
+
failed_services.append(service)
|
|
115
|
+
logger.exception("Failed to stop background service")
|
|
116
|
+
except BaseException:
|
|
117
|
+
self._state = ServiceState.FAILED
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
self._active_services = list(reversed(failed_services))
|
|
121
|
+
if errors:
|
|
122
|
+
self._state = ServiceState.FAILED
|
|
123
|
+
raise ExceptionGroup("one or more services failed to stop", errors)
|
|
124
|
+
self._state = ServiceState.STOPPED
|
|
125
|
+
logger.info("Stopped all background services")
|
|
126
|
+
|
|
127
|
+
@asynccontextmanager
|
|
128
|
+
async def lifespan(self) -> AsyncIterator[None]:
|
|
129
|
+
"""Run the managed services for the duration of an async context."""
|
|
130
|
+
await self.start()
|
|
131
|
+
try:
|
|
132
|
+
yield
|
|
133
|
+
finally:
|
|
134
|
+
await self.stop()
|
|
135
|
+
|
|
136
|
+
def fastapi_lifespan(
|
|
137
|
+
self,
|
|
138
|
+
setup_handler: SetupHandler | None = None,
|
|
139
|
+
) -> Callable[[FastAPI], AbstractAsyncContextManager[None]]:
|
|
140
|
+
"""Return an ASGI lifespan callable for :class:`FastAPI`."""
|
|
141
|
+
if setup_handler is not None:
|
|
142
|
+
self.set_setup_handler(setup_handler)
|
|
143
|
+
|
|
144
|
+
@asynccontextmanager
|
|
145
|
+
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|
146
|
+
async with self.lifespan():
|
|
147
|
+
yield
|
|
148
|
+
|
|
149
|
+
return lifespan
|
|
150
|
+
|
|
151
|
+
async def _cleanup_after_start_failure(
|
|
152
|
+
self,
|
|
153
|
+
failed_service: BackgroundServiceProtocol,
|
|
154
|
+
started_services: list[BackgroundServiceProtocol],
|
|
155
|
+
startup_error: BaseException,
|
|
156
|
+
) -> list[BackgroundServiceProtocol]:
|
|
157
|
+
"""Clean the failing service and every earlier service in reverse order."""
|
|
158
|
+
cleanup_failures: list[BackgroundServiceProtocol] = []
|
|
159
|
+
for service in (failed_service, *reversed(started_services)):
|
|
160
|
+
try:
|
|
161
|
+
await service.stop()
|
|
162
|
+
except BaseException as cleanup_error:
|
|
163
|
+
cleanup_failures.append(service)
|
|
164
|
+
startup_error.add_note(
|
|
165
|
+
f"cleanup failed for {type(service).__name__}: {type(cleanup_error).__name__}"
|
|
166
|
+
)
|
|
167
|
+
logger.error(
|
|
168
|
+
"Cleanup failed for a background service after startup failure",
|
|
169
|
+
exc_info=(
|
|
170
|
+
type(cleanup_error),
|
|
171
|
+
cleanup_error,
|
|
172
|
+
cleanup_error.__traceback__,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
return cleanup_failures
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Base class that registers a service's routes on an injected application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIService(ABC):
|
|
11
|
+
"""Attach one route group to an existing FastAPI application."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, app: FastAPI, name: str | None = None) -> None:
|
|
14
|
+
"""Store the app and register routes implemented by the subclass."""
|
|
15
|
+
self.app = app
|
|
16
|
+
self.name = name or type(self).__name__
|
|
17
|
+
self._setup_routes()
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def _setup_routes(self) -> None:
|
|
21
|
+
"""Register this service's routes on :attr:`app`."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Compose a FastAPI application from explicit configuration and services."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
9
|
+
from std.fastapi.api.config import APIConfig, Environment
|
|
10
|
+
from std.fastapi.api.lifecycle import LifecycleManager
|
|
11
|
+
from std.logging import LoggingConfig, configure_logging
|
|
12
|
+
from std.service import BackgroundServiceProtocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FastAPISetup:
|
|
16
|
+
"""Build a FastAPI app with metadata, CORS, logging, and service lifespan."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
api_config: APIConfig,
|
|
21
|
+
log_config: LoggingConfig | Mapping[str, object] | None = None,
|
|
22
|
+
setup_handler: Callable[[], Awaitable[Sequence[BackgroundServiceProtocol]]] | None = None,
|
|
23
|
+
*,
|
|
24
|
+
services: Sequence[BackgroundServiceProtocol] = (),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Store application settings and constructed background services."""
|
|
27
|
+
self.api_config = api_config
|
|
28
|
+
self.log_config = log_config
|
|
29
|
+
self.lifecycle_manager = LifecycleManager(services, setup_handler=setup_handler)
|
|
30
|
+
|
|
31
|
+
def create_fastapi_app(self) -> FastAPI:
|
|
32
|
+
"""Create a configured FastAPI instance with injected service lifespan."""
|
|
33
|
+
if self.log_config is not None:
|
|
34
|
+
environment = {
|
|
35
|
+
Environment.DEV: "development",
|
|
36
|
+
Environment.STAGE: "staging",
|
|
37
|
+
Environment.PROD: "production",
|
|
38
|
+
}[self.api_config.env]
|
|
39
|
+
configure_logging(self.log_config, environment=environment)
|
|
40
|
+
|
|
41
|
+
app = FastAPI(
|
|
42
|
+
title=self.api_config.title,
|
|
43
|
+
description=self.api_config.description,
|
|
44
|
+
version=self.api_config.version,
|
|
45
|
+
debug=self.api_config.debug,
|
|
46
|
+
lifespan=self.lifecycle_manager.fastapi_lifespan(),
|
|
47
|
+
)
|
|
48
|
+
app.add_middleware(
|
|
49
|
+
CORSMiddleware,
|
|
50
|
+
allow_origins=self.api_config.cors_origins,
|
|
51
|
+
allow_credentials=self.api_config.cors_credentials,
|
|
52
|
+
allow_methods=self.api_config.cors_methods,
|
|
53
|
+
allow_headers=self.api_config.cors_headers,
|
|
54
|
+
)
|
|
55
|
+
return app
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Header token authentication for FastAPI dependencies and middleware."""
|
|
2
|
+
|
|
3
|
+
from std.fastapi.auth.config import AppTokenConfig
|
|
4
|
+
from std.fastapi.auth.middleware import (
|
|
5
|
+
AppTokenAuth,
|
|
6
|
+
TokenAuthMiddleware,
|
|
7
|
+
add_api_key_security_scheme,
|
|
8
|
+
apply_token_auth_middleware,
|
|
9
|
+
setup_app_token_auth,
|
|
10
|
+
setup_token_auth,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AppTokenAuth",
|
|
15
|
+
"AppTokenConfig",
|
|
16
|
+
"TokenAuthMiddleware",
|
|
17
|
+
"add_api_key_security_scheme",
|
|
18
|
+
"apply_token_auth_middleware",
|
|
19
|
+
"setup_app_token_auth",
|
|
20
|
+
"setup_token_auth",
|
|
21
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Validated settings for application-token authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
|
|
9
|
+
_HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AppTokenConfig(BaseModel):
|
|
13
|
+
"""Configure the token header, accepted application tokens, and path skips."""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(extra="forbid")
|
|
16
|
+
|
|
17
|
+
header_name: str = "X-App-Token"
|
|
18
|
+
tokens: dict[str, str] = Field(default_factory=dict, repr=False)
|
|
19
|
+
enabled: bool = True
|
|
20
|
+
exclude_paths: set[str] = Field(default_factory=set)
|
|
21
|
+
|
|
22
|
+
@field_validator("header_name")
|
|
23
|
+
@classmethod
|
|
24
|
+
def validate_header_name(cls, value: str) -> str:
|
|
25
|
+
"""Require a valid HTTP token header name."""
|
|
26
|
+
name = value.strip()
|
|
27
|
+
if not _HEADER_NAME.fullmatch(name):
|
|
28
|
+
raise ValueError("header_name must be a valid HTTP field name")
|
|
29
|
+
return name
|
|
30
|
+
|
|
31
|
+
@field_validator("tokens")
|
|
32
|
+
@classmethod
|
|
33
|
+
def validate_tokens(cls, tokens: dict[str, str]) -> dict[str, str]:
|
|
34
|
+
"""Reject blank identities and tokens without disclosing token values."""
|
|
35
|
+
seen: set[str] = set()
|
|
36
|
+
for app_name, token in tokens.items():
|
|
37
|
+
if not app_name.strip():
|
|
38
|
+
raise ValueError("token application names cannot be empty")
|
|
39
|
+
if not token.strip():
|
|
40
|
+
raise ValueError(f"token for application {app_name!r} cannot be empty")
|
|
41
|
+
if token in seen:
|
|
42
|
+
raise ValueError("authentication tokens must be unique")
|
|
43
|
+
seen.add(token)
|
|
44
|
+
return tokens
|
|
45
|
+
|
|
46
|
+
@field_validator("exclude_paths")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_exclude_paths(cls, paths: set[str]) -> set[str]:
|
|
49
|
+
"""Require absolute request paths for exact and wildcard exclusions."""
|
|
50
|
+
normalized = {path.strip() for path in paths}
|
|
51
|
+
if any(not path.startswith("/") for path in normalized):
|
|
52
|
+
raise ValueError("excluded paths must begin with '/'")
|
|
53
|
+
return normalized
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""API-token dependency, middleware, and OpenAPI security helpers."""
|
|
2
|
+
|
|
3
|
+
import hmac
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from fnmatch import fnmatchcase
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
9
|
+
from starlette.types import ASGIApp
|
|
10
|
+
|
|
11
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
from std.fastapi.auth.config import AppTokenConfig
|
|
14
|
+
|
|
15
|
+
MissingTokenHandler = Callable[[Request], Awaitable[Response]]
|
|
16
|
+
InvalidTokenHandler = Callable[[Request, str], Awaitable[Response]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AppTokenAuth:
|
|
20
|
+
"""Authenticate requests from one configured header and return the app ID."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, settings: AppTokenConfig | None = None) -> None:
|
|
23
|
+
"""Use the supplied configuration or the secure default configuration."""
|
|
24
|
+
self.settings = settings or AppTokenConfig()
|
|
25
|
+
|
|
26
|
+
async def __call__(self, request: Request) -> str | None:
|
|
27
|
+
"""Return the matching app name or raise a 401 for a protected path."""
|
|
28
|
+
if not self.settings.enabled or self.is_path_excluded(request.url.path):
|
|
29
|
+
return None
|
|
30
|
+
token = request.headers.get(self.settings.header_name)
|
|
31
|
+
if token is None:
|
|
32
|
+
raise HTTPException(status_code=401, detail="Missing authentication token")
|
|
33
|
+
app_name = self.get_app_name(token)
|
|
34
|
+
if app_name is None:
|
|
35
|
+
raise HTTPException(status_code=401, detail="Invalid authentication token")
|
|
36
|
+
return app_name
|
|
37
|
+
|
|
38
|
+
def is_path_excluded(self, path: str) -> bool:
|
|
39
|
+
"""Return whether a path matches an exact or shell-style exclusion."""
|
|
40
|
+
return any(fnmatchcase(path, pattern) for pattern in self.settings.exclude_paths)
|
|
41
|
+
|
|
42
|
+
def get_app_name(self, token: str) -> str | None:
|
|
43
|
+
"""Return the identity associated with a token using constant-time checks."""
|
|
44
|
+
supplied = token.encode("utf-8")
|
|
45
|
+
matched_app: str | None = None
|
|
46
|
+
for app_name, accepted in self.settings.tokens.items():
|
|
47
|
+
if hmac.compare_digest(supplied, accepted.encode("utf-8")):
|
|
48
|
+
matched_app = app_name
|
|
49
|
+
return matched_app
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TokenAuthMiddleware(BaseHTTPMiddleware):
|
|
53
|
+
"""Require a configured API token for every non-excluded request."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
app: ASGIApp,
|
|
58
|
+
settings: AppTokenConfig | None = None,
|
|
59
|
+
on_missing_token: MissingTokenHandler | None = None,
|
|
60
|
+
on_invalid_token: InvalidTokenHandler | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
"""Store authentication settings and optional async response handlers."""
|
|
63
|
+
super().__init__(app)
|
|
64
|
+
self.auth = AppTokenAuth(settings)
|
|
65
|
+
self.on_missing_token = on_missing_token
|
|
66
|
+
self.on_invalid_token = on_invalid_token
|
|
67
|
+
|
|
68
|
+
async def dispatch(
|
|
69
|
+
self,
|
|
70
|
+
request: Request,
|
|
71
|
+
call_next: RequestResponseEndpoint,
|
|
72
|
+
) -> Response:
|
|
73
|
+
"""Validate credentials, set request identity, then continue the request."""
|
|
74
|
+
settings = self.auth.settings
|
|
75
|
+
if not settings.enabled or self.auth.is_path_excluded(request.url.path):
|
|
76
|
+
return await call_next(request)
|
|
77
|
+
|
|
78
|
+
token = request.headers.get(settings.header_name)
|
|
79
|
+
if token is None:
|
|
80
|
+
if self.on_missing_token is not None:
|
|
81
|
+
return await self.on_missing_token(request)
|
|
82
|
+
return JSONResponse({"error": "Missing authentication token"}, status_code=401)
|
|
83
|
+
|
|
84
|
+
app_name = self.auth.get_app_name(token)
|
|
85
|
+
if app_name is None:
|
|
86
|
+
if self.on_invalid_token is not None:
|
|
87
|
+
return await self.on_invalid_token(request, token)
|
|
88
|
+
return JSONResponse({"error": "Invalid authentication token"}, status_code=401)
|
|
89
|
+
|
|
90
|
+
request.state.app_name = app_name
|
|
91
|
+
return await call_next(request)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def add_api_key_security_scheme(
|
|
95
|
+
app: FastAPI,
|
|
96
|
+
header_name: str,
|
|
97
|
+
enabled: bool = True,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Add an API-key scheme and global security requirement to generated OpenAPI."""
|
|
100
|
+
if not enabled:
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
original_openapi = app.openapi
|
|
104
|
+
|
|
105
|
+
def openapi_with_api_key() -> dict[str, Any]:
|
|
106
|
+
schema = original_openapi()
|
|
107
|
+
components = schema.get("components")
|
|
108
|
+
if not isinstance(components, dict):
|
|
109
|
+
components = {}
|
|
110
|
+
schema["components"] = components
|
|
111
|
+
schemes = components.setdefault("securitySchemes", {})
|
|
112
|
+
schemes["APIKeyHeader"] = {"type": "apiKey", "in": "header", "name": header_name}
|
|
113
|
+
requirement: dict[str, list[str]] = {"APIKeyHeader": []}
|
|
114
|
+
security = schema.get("security")
|
|
115
|
+
if not isinstance(security, list):
|
|
116
|
+
security = []
|
|
117
|
+
schema["security"] = security
|
|
118
|
+
if requirement not in security:
|
|
119
|
+
security.append(requirement)
|
|
120
|
+
app.openapi_schema = schema
|
|
121
|
+
return schema
|
|
122
|
+
|
|
123
|
+
# FastAPI has no setter for this override; direct assignment fails strict mypy.
|
|
124
|
+
setattr(app, "openapi", openapi_with_api_key) # noqa: B010
|
|
125
|
+
if app.openapi_schema is not None:
|
|
126
|
+
openapi_with_api_key()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def setup_token_auth(
|
|
130
|
+
app: FastAPI,
|
|
131
|
+
settings: AppTokenConfig | None = None,
|
|
132
|
+
) -> AppTokenAuth:
|
|
133
|
+
"""Register the OpenAPI security scheme and return a dependency handler."""
|
|
134
|
+
config = settings or AppTokenConfig()
|
|
135
|
+
add_api_key_security_scheme(app, config.header_name, config.enabled)
|
|
136
|
+
return AppTokenAuth(config)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply_token_auth_middleware(
|
|
140
|
+
app: FastAPI,
|
|
141
|
+
settings: AppTokenConfig | None = None,
|
|
142
|
+
on_missing_token: MissingTokenHandler | None = None,
|
|
143
|
+
on_invalid_token: InvalidTokenHandler | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Install token authentication middleware and document its header scheme."""
|
|
146
|
+
config = settings or AppTokenConfig()
|
|
147
|
+
add_api_key_security_scheme(app, config.header_name, config.enabled)
|
|
148
|
+
app.add_middleware(
|
|
149
|
+
TokenAuthMiddleware,
|
|
150
|
+
settings=config,
|
|
151
|
+
on_missing_token=on_missing_token,
|
|
152
|
+
on_invalid_token=on_invalid_token,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
setup_app_token_auth = setup_token_auth
|
|
157
|
+
|
|
158
|
+
__all__ = [
|
|
159
|
+
"AppTokenAuth",
|
|
160
|
+
"TokenAuthMiddleware",
|
|
161
|
+
"add_api_key_security_scheme",
|
|
162
|
+
"apply_token_auth_middleware",
|
|
163
|
+
"setup_app_token_auth",
|
|
164
|
+
"setup_token_auth",
|
|
165
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""FastAPI dependencies that construct validated Pydantic Settings models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
from pydantic_settings import BaseSettings
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException, Request
|
|
10
|
+
from fastapi.params import Depends as DependsType
|
|
11
|
+
|
|
12
|
+
SettingsT = TypeVar("SettingsT", bound=BaseSettings)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def settings_dependency(settings_type: type[SettingsT]) -> DependsType:
|
|
16
|
+
"""Return a dependency that retrieves main-initialized app settings.
|
|
17
|
+
|
|
18
|
+
Construct the model once in the application composition root and assign it
|
|
19
|
+
to ``app.state.settings`` before serving requests. The dependency verifies
|
|
20
|
+
its type and returns that same instance; it never constructs settings.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(settings_type, type) or not issubclass(settings_type, BaseSettings):
|
|
23
|
+
raise TypeError("settings_type must be a BaseSettings subclass")
|
|
24
|
+
|
|
25
|
+
async def get_settings(request: Request) -> SettingsT:
|
|
26
|
+
settings = getattr(request.app.state, "settings", None)
|
|
27
|
+
if not isinstance(settings, settings_type):
|
|
28
|
+
raise HTTPException(
|
|
29
|
+
status_code=500,
|
|
30
|
+
detail="Application settings were not initialized in the composition root",
|
|
31
|
+
)
|
|
32
|
+
return settings
|
|
33
|
+
|
|
34
|
+
return DependsType(get_settings)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
__all__ = ["settings_dependency"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Health response models and endpoint registration."""
|
|
2
|
+
|
|
3
|
+
from std.fastapi.health.endpoint import HealthCheck, HealthCheckFunc, HealthResponse
|
|
4
|
+
from std.fastapi.health.endpoint import create_health_endpoint as create_health_endpoint
|
|
5
|
+
|
|
6
|
+
__all__ = ["HealthCheck", "HealthCheckFunc", "HealthResponse", "create_health_endpoint"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Register a health endpoint backed by synchronous or asynchronous checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
HealthCheckFunc = Callable[[], Any | Awaitable[Any]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HealthResponse(BaseModel):
|
|
20
|
+
"""JSON response containing the overall state and individual check values."""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(extra="forbid")
|
|
23
|
+
|
|
24
|
+
status: str = "healthy"
|
|
25
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HealthCheck:
|
|
29
|
+
"""Collect named checks and expose their aggregate result as an endpoint."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, app: FastAPI | None = None, path: str = "/health") -> None:
|
|
32
|
+
"""Create a health endpoint, optionally registering it on an app."""
|
|
33
|
+
if not path.startswith("/"):
|
|
34
|
+
raise ValueError("health endpoint path must begin with '/'")
|
|
35
|
+
self.path = path
|
|
36
|
+
self._checks: list[tuple[str, HealthCheckFunc]] = []
|
|
37
|
+
self._registered_apps: set[int] = set()
|
|
38
|
+
if app is not None:
|
|
39
|
+
self.add_to_app(app)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def checks(self) -> tuple[tuple[str, HealthCheckFunc], ...]:
|
|
43
|
+
"""Return the registered checks in execution order."""
|
|
44
|
+
return tuple(self._checks)
|
|
45
|
+
|
|
46
|
+
def add_check(self, name: str, check_func: HealthCheckFunc) -> None:
|
|
47
|
+
"""Register a named synchronous or asynchronous health check."""
|
|
48
|
+
normalized_name = name.strip()
|
|
49
|
+
if not normalized_name:
|
|
50
|
+
raise ValueError("health check name cannot be empty")
|
|
51
|
+
if not callable(check_func):
|
|
52
|
+
raise TypeError("check_func must be callable")
|
|
53
|
+
if any(existing == normalized_name for existing, _ in self._checks):
|
|
54
|
+
raise ValueError(f"health check {normalized_name!r} is already registered")
|
|
55
|
+
self._checks.append((normalized_name, check_func))
|
|
56
|
+
|
|
57
|
+
def add_to_app(self, app: FastAPI, include_in_schema: bool = False) -> None:
|
|
58
|
+
"""Register the health route on an app once."""
|
|
59
|
+
app_identity = id(app)
|
|
60
|
+
if app_identity in self._registered_apps:
|
|
61
|
+
raise ValueError("health endpoint is already registered on this app")
|
|
62
|
+
self._registered_apps.add(app_identity)
|
|
63
|
+
|
|
64
|
+
@app.get(
|
|
65
|
+
self.path,
|
|
66
|
+
response_model=HealthResponse,
|
|
67
|
+
include_in_schema=include_in_schema,
|
|
68
|
+
tags=["health"],
|
|
69
|
+
)
|
|
70
|
+
async def health_check() -> HealthResponse:
|
|
71
|
+
details: dict[str, Any] = {}
|
|
72
|
+
healthy = True
|
|
73
|
+
for name, check_func in self._checks:
|
|
74
|
+
try:
|
|
75
|
+
result = await self._execute(check_func)
|
|
76
|
+
except Exception as error:
|
|
77
|
+
logger.warning("Health check %s failed (%s)", name, type(error).__name__)
|
|
78
|
+
result = {"status": "error", "error": type(error).__name__}
|
|
79
|
+
details[name] = result
|
|
80
|
+
if self._is_unhealthy(result):
|
|
81
|
+
healthy = False
|
|
82
|
+
return HealthResponse(status="healthy" if healthy else "unhealthy", details=details)
|
|
83
|
+
|
|
84
|
+
async def _execute(self, check_func: HealthCheckFunc) -> Any:
|
|
85
|
+
if inspect.iscoroutinefunction(check_func):
|
|
86
|
+
result = check_func()
|
|
87
|
+
else:
|
|
88
|
+
sync_check = cast(Callable[[], Any], check_func)
|
|
89
|
+
result = await asyncio.get_running_loop().run_in_executor(None, sync_check)
|
|
90
|
+
if inspect.isawaitable(result):
|
|
91
|
+
return await result
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _is_unhealthy(result: Any) -> bool:
|
|
96
|
+
if result is False:
|
|
97
|
+
return True
|
|
98
|
+
if isinstance(result, Mapping):
|
|
99
|
+
status = result.get("status")
|
|
100
|
+
return isinstance(status, str) and status.casefold() in {
|
|
101
|
+
"error",
|
|
102
|
+
"unhealthy",
|
|
103
|
+
"down",
|
|
104
|
+
}
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def create_health_endpoint(
|
|
109
|
+
app: FastAPI,
|
|
110
|
+
path: str = "/health",
|
|
111
|
+
include_in_schema: bool = False,
|
|
112
|
+
checks: list[tuple[str, HealthCheckFunc]] | None = None,
|
|
113
|
+
) -> HealthCheck:
|
|
114
|
+
"""Create a health manager, register initial checks, and attach its route."""
|
|
115
|
+
health = HealthCheck(path=path)
|
|
116
|
+
for name, check_func in checks or []:
|
|
117
|
+
health.add_check(name, check_func)
|
|
118
|
+
health.add_to_app(app, include_in_schema=include_in_schema)
|
|
119
|
+
return health
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
__all__ = ["HealthCheck", "HealthCheckFunc", "HealthResponse", "create_health_endpoint"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Log HTTP request, response, and timing fields without reading request bodies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from time import perf_counter
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
9
|
+
from starlette.types import ASGIApp
|
|
10
|
+
|
|
11
|
+
from fastapi import Request, Response
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoggingMiddleware(BaseHTTPMiddleware):
|
|
17
|
+
"""Log method, path, status, elapsed time, and optional client address."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
app: ASGIApp,
|
|
22
|
+
*,
|
|
23
|
+
include_client: bool = True,
|
|
24
|
+
process_time_header: str = "X-Process-Time",
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Configure client logging and the response timing header."""
|
|
27
|
+
super().__init__(app)
|
|
28
|
+
self.include_client = include_client
|
|
29
|
+
self.process_time_header = process_time_header
|
|
30
|
+
|
|
31
|
+
async def dispatch(
|
|
32
|
+
self,
|
|
33
|
+
request: Request,
|
|
34
|
+
call_next: RequestResponseEndpoint,
|
|
35
|
+
) -> Response:
|
|
36
|
+
"""Process a request, log its outcome, and add an elapsed-time header."""
|
|
37
|
+
start = perf_counter()
|
|
38
|
+
client = request.client.host if self.include_client and request.client else None
|
|
39
|
+
fields: dict[str, object] = {
|
|
40
|
+
"http.method": request.method,
|
|
41
|
+
"http.path": request.url.path,
|
|
42
|
+
}
|
|
43
|
+
if client is not None:
|
|
44
|
+
fields["client.address"] = client
|
|
45
|
+
logger.info("HTTP request started", extra=fields)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
response = await call_next(request)
|
|
49
|
+
except Exception:
|
|
50
|
+
duration_ms = (perf_counter() - start) * 1000
|
|
51
|
+
logger.exception(
|
|
52
|
+
"HTTP request failed",
|
|
53
|
+
extra={**fields, "http.duration_ms": round(duration_ms, 3)},
|
|
54
|
+
)
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
duration_ms = (perf_counter() - start) * 1000
|
|
58
|
+
response.headers[self.process_time_header] = f"{duration_ms:.1f}ms"
|
|
59
|
+
logger.info(
|
|
60
|
+
"HTTP response finished",
|
|
61
|
+
extra={
|
|
62
|
+
**fields,
|
|
63
|
+
"http.status_code": response.status_code,
|
|
64
|
+
"http.duration_ms": round(duration_ms, 3),
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
return response
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["LoggingMiddleware"]
|