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.
@@ -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,5 @@
1
+ """HTTP request logging middleware."""
2
+
3
+ from std.fastapi.logging.middleware import LoggingMiddleware
4
+
5
+ __all__ = ["LoggingMiddleware"]
@@ -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"]