From 34c13474026670a3f251aacee6b19ed88db107ca Mon Sep 17 00:00:00 2001 From: Aleksei Sokol Date: Sun, 30 Nov 2025 16:59:25 +0300 Subject: [PATCH] Version 0.2.0 Changes: - add metrics dispencer - add basic authentication dependency - enable GZIP middleware - add !env() example to deploy section - update dependencies state attribute name --- README.md | 14 +++-- copier.yml | 4 +- deploy/configs/api.yaml.jinja | 2 +- deploy/docker-compose.yaml.jinja | 2 +- poetry.lock | 20 ++++++- pyproject.toml.jinja | 1 + {{project_slug}}/__main__.py.jinja | 2 +- {{project_slug}}/config.py.jinja | 2 +- {{project_slug}}/dependencies/auth_dep.py | 34 ++++++++++++ .../connection_manager_dep.py.jinja | 18 +++--- {{project_slug}}/dependencies/logger_dep.py | 34 +++++++----- .../dependencies/metrics_dep.py.jinja | 26 +++++++++ {{project_slug}}/fastapi_init.py.jinja | 5 +- {{project_slug}}/handlers/debug.py.jinja | 23 +++++--- .../middlewares/observability.py.jinja | 12 ++-- {{project_slug}}/observability/metrics.py | 55 +++++++++++-------- 16 files changed, 180 insertions(+), 74 deletions(-) create mode 100644 {{project_slug}}/dependencies/auth_dep.py create mode 100644 {{project_slug}}/dependencies/metrics_dep.py.jinja diff --git a/README.md b/README.md index e568cc8..b7c7b25 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ -## template-api +## template-fastapi -This is a repository which contains a template for a FastAPI python service +This is a repository which contains a template for a backend python microservice running a FastAPI framework -## Tsage +Version 0.2.0 -To use a template you need to use `cookiecutter` +## Usage -1. `pipx install cookiecutter` -2. `cookiecutter -o ` \ No newline at end of file +To use a template you need to use `copier` + +1. `pipx install copier` +2. `copier git+https:// ` diff --git a/copier.yml b/copier.yml index 755aea4..b46b6bf 100644 --- a/copier.yml +++ b/copier.yml @@ -27,6 +27,6 @@ vcs_type: default: gitea _exclude: - - copier.yaml + - copier.yml - .git - - .gitea/workflows/validate.yaml \ No newline at end of file + - .gitea/workflows/validate.yaml diff --git a/deploy/configs/api.yaml.jinja b/deploy/configs/api.yaml.jinja index 87d54d9..9b7e487 100644 --- a/deploy/configs/api.yaml.jinja +++ b/deploy/configs/api.yaml.jinja @@ -13,7 +13,7 @@ db: port: 5432 database: {{project_slug}}_db user: postgres - password: postgres + password: "!env(DB_PASSWORD)" pool_size: 2 logging: level: INFO diff --git a/deploy/docker-compose.yaml.jinja b/deploy/docker-compose.yaml.jinja index 0807064..99c5611 100644 --- a/deploy/docker-compose.yaml.jinja +++ b/deploy/docker-compose.yaml.jinja @@ -46,6 +46,7 @@ services: dockerfile: deploy/Dockerfile environment: &api-environment-section CONFIG_PATH: /app/config.yaml + DB_PASSWORD: postgres volumes: &api-volumes-section - ./configs/api.yaml:/app/config.yaml depends_on: @@ -143,4 +144,3 @@ services: depends_on: - jaeger logging: *json-logging - diff --git a/poetry.lock b/poetry.lock index fc8745b..bd82ac8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1632,6 +1632,24 @@ files = [ [package.dependencies] typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pylint" version = "3.3.8" @@ -2171,4 +2189,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">= 3.11" -content-hash = "d3603a66f7b46bafe3258b196513b3721fad22b315429bad4e58eb754c4a2e51" +content-hash = "7482a5c9526ee5e4fe0806e90bf066dedb5a0cdb122da5322d44e1a632527067" diff --git a/pyproject.toml.jinja b/pyproject.toml.jinja index d5688fe..0c5071a 100644 --- a/pyproject.toml.jinja +++ b/pyproject.toml.jinja @@ -20,6 +20,7 @@ dependencies = [ "opentelemetry-semantic-conventions (>=0.59b0,<0.60)", "aiohttp (>=3.13.2,<4.0.0)", "email-validator (>=2.3.0,<3.0.0)", + "pyjwt (>=2.10.1,<3.0.0)", ] [build-system] diff --git a/{{project_slug}}/__main__.py.jinja b/{{project_slug}}/__main__.py.jinja index cbcf209..d638525 100644 --- a/{{project_slug}}/__main__.py.jinja +++ b/{{project_slug}}/__main__.py.jinja @@ -9,7 +9,7 @@ import click import uvicorn from dotenv import load_dotenv -from .config import {{ProjectName}}Config, LoggingConfig +from .config import LoggingConfig, {{ProjectName}}Config LogLevel = tp.Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"] diff --git a/{{project_slug}}/config.py.jinja b/{{project_slug}}/config.py.jinja index 3d5f6eb..a4ae36a 100644 --- a/{{project_slug}}/config.py.jinja +++ b/{{project_slug}}/config.py.jinja @@ -113,7 +113,7 @@ class {{ProjectName}}Config: """Generate an example of configuration.""" res = cls( - app=AppConfig(host="0.0.0.0", port=8000, debug=False, cors=CORSConfig(["*"], ["*"], ["*"], True)), + app=AppConfig(host="0.0.0.0", port=8080, debug=False, cors=CORSConfig(["*"], ["*"], ["*"], True)), db=MultipleDBsConfig( master=DBConfig( host="localhost", diff --git a/{{project_slug}}/dependencies/auth_dep.py b/{{project_slug}}/dependencies/auth_dep.py new file mode 100644 index 0000000..899ff24 --- /dev/null +++ b/{{project_slug}}/dependencies/auth_dep.py @@ -0,0 +1,34 @@ +"""Authentication dependency function is defined here.""" + +from dataclasses import dataclass + +import jwt +from fastapi import Request + +from . import logger_dep + + +@dataclass +class AuthenticationData: + api_key: str | None + jwt_payload: dict | None + jwt_original: str | None + + +def obtain(request: Request) -> AuthenticationData: + if hasattr(request.state, "auth_dep"): + return request.state.auth_dep + auth = AuthenticationData(None, None, None) + if (value := request.headers.get("X-API-Key")) is not None: + auth.api_key = value + if (value := request.headers.get("Authorization")) is not None and value.startswith("Bearer "): + value = value[7:] + auth.jwt_original = value + try: + auth.jwt_payload = jwt.decode(value, algorithms=["HS256"], options={"verify_signature": False}) + except Exception: # pylint: disable=broad-except + logger = logger_dep.obtain(request) + logger.warning("failed to parse Authorization header as jwt", value=value) + logger.debug("failed to parse Authorization header as jwt", exc_info=True) + request.state.auth_dep = auth + return auth diff --git a/{{project_slug}}/dependencies/connection_manager_dep.py.jinja b/{{project_slug}}/dependencies/connection_manager_dep.py.jinja index e42b5c3..4262b92 100644 --- a/{{project_slug}}/dependencies/connection_manager_dep.py.jinja +++ b/{{project_slug}}/dependencies/connection_manager_dep.py.jinja @@ -7,19 +7,21 @@ from {{project_slug}}.db.connection.manager import PostgresConnectionManager def init_dispencer(app: FastAPI, connection_manager: PostgresConnectionManager) -> None: """Initialize PostgresConnectionManager dispencer at app's state.""" - if hasattr(app.state, "postgres_connection_manager"): - if not isinstance(app.state.postgres_connection_manager, PostgresConnectionManager): + if hasattr(app.state, "postgres_connection_manager_dep"): + if not isinstance(app.state.postgres_connection_manager_dep, PostgresConnectionManager): raise ValueError( - "postgres_connection_manager attribute of app's state is already set" - f"with other value ({app.state.postgres_connection_manager})" + "postgres_connection_manager_dep attribute of app's state is already set" + f"with other value ({app.state.postgres_connection_manager_dep})" ) return - app.state.postgres_connection_manager = connection_manager + app.state.postgres_connection_manager_dep = connection_manager -def obtain(request: Request) -> PostgresConnectionManager: +def obtain(app_or_request: FastAPI | Request) -> PostgresConnectionManager: """Get a PostgresConnectionManager from request's app state.""" - if not hasattr(request.app.state, "postgres_connection_manager"): + if isinstance(app_or_request, Request): + app_or_request = app_or_request.app + if not hasattr(app_or_request.state, "postgres_connection_manager_dep"): raise ValueError("PostgresConnectionManager dispencer was not initialized at app preparation") - return request.app.state.postgres_connection_manager + return app_or_request.state.postgres_connection_manager_dep diff --git a/{{project_slug}}/dependencies/logger_dep.py b/{{project_slug}}/dependencies/logger_dep.py index 8d17c27..0b68d6f 100644 --- a/{{project_slug}}/dependencies/logger_dep.py +++ b/{{project_slug}}/dependencies/logger_dep.py @@ -1,4 +1,4 @@ -"""PostgresConnectionManager dependency functions are defined here.""" +"""structlog BoundLogger dependency functions are defined here.""" from fastapi import FastAPI, Request from structlog.stdlib import BoundLogger @@ -7,27 +7,31 @@ from structlog.stdlib import BoundLogger def init_dispencer(app: FastAPI, logger: BoundLogger) -> None: """Initialize BoundLogger dispencer at app's state.""" if hasattr(app.state, "logger"): - if not isinstance(app.state.logger, BoundLogger): - raise ValueError("logger attribute of app's state is already set" f"with other value ({app.state.logger})") + if not isinstance(app.state.logger_dep, BoundLogger): + raise ValueError( + "logger attribute of app's state is already set" f"with other value ({app.state.logger_dep})" + ) return - app.state.logger = logger + app.state.logger_dep = logger def attach_to_request(request: Request, logger: BoundLogger) -> None: """Set logger for a concrete request. If request had already had a logger, replace it.""" - if hasattr(request.state, "logger"): - if not isinstance(request.state.logger, BoundLogger): - logger.warning("request.state.logger is already set with other value", value=request.state.logger) - request.state.logger = logger + if hasattr(request.state, "logger_dep"): + if not isinstance(request.state.logger_dep, BoundLogger): + logger.warning("request.state.logger is already set with other value", value=request.state.logger_dep) + request.state.logger_dep = logger -def obtain(request: Request) -> BoundLogger: +def obtain(app_or_request: FastAPI | Request) -> BoundLogger: """Get a logger from request or app state.""" - if hasattr(request.state, "logger"): - logger = request.state.logger - if isinstance(logger, BoundLogger): - return logger - if not hasattr(request.app.state, "logger"): + if isinstance(app_or_request, Request): + if hasattr(app_or_request.state, "logger_dep"): + logger = app_or_request.state.logger_dep + if isinstance(logger, BoundLogger): + return logger + app_or_request = app_or_request.app + if not hasattr(app_or_request.state, "logger_dep"): raise ValueError("BoundLogger dispencer was not initialized at app preparation") - return request.app.state.logger + return app_or_request.state.logger_dep diff --git a/{{project_slug}}/dependencies/metrics_dep.py.jinja b/{{project_slug}}/dependencies/metrics_dep.py.jinja new file mode 100644 index 0000000..4eafd60 --- /dev/null +++ b/{{project_slug}}/dependencies/metrics_dep.py.jinja @@ -0,0 +1,26 @@ +"""Metrics dependency functions are defined here.""" + +from fastapi import FastAPI, Request + +from {{project_slug}}.observability.metrics import Metrics + + +def init_dispencer(app: FastAPI, connection_manager: Metrics) -> None: + """Initialize Metrics dispencer at app's state.""" + if hasattr(app.state, "metrics_dep"): + if not isinstance(app.state.metrics_dep, Metrics): + raise ValueError( + "metrics_dep attribute of app's state is already set" f"with other value ({app.state.metrics_dep})" + ) + return + + app.state.metrics_dep = connection_manager + + +def obtain(app_or_request: FastAPI | Request) -> Metrics: + """Get a Metrics from request's app state.""" + if isinstance(app_or_request, Request): + app_or_request = app_or_request.app + if not hasattr(app_or_request.state, "metrics_dep"): + raise ValueError("Metrics dispencer was not initialized at app preparation") + return app_or_request.state.metrics_dep diff --git a/{{project_slug}}/fastapi_init.py.jinja b/{{project_slug}}/fastapi_init.py.jinja index a83b663..86c321b 100644 --- a/{{project_slug}}/fastapi_init.py.jinja +++ b/{{project_slug}}/fastapi_init.py.jinja @@ -5,12 +5,13 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from fastapi.openapi.docs import get_swagger_ui_html from fastapi.responses import JSONResponse from {{project_slug}}.config import {{ProjectName}}Config from {{project_slug}}.db.connection.manager import PostgresConnectionManager -from {{project_slug}}.dependencies import connection_manager_dep, logger_dep +from {{project_slug}}.dependencies import connection_manager_dep, logger_dep, metrics_dep from {{project_slug}}.exceptions.mapper import ExceptionMapper from {{project_slug}}.handlers.debug import DebugException, DebugExceptionWithParams from {{project_slug}}.middlewares.exception_handler import ExceptionHandlerMiddleware @@ -90,11 +91,13 @@ def get_app(prefix: str = "/api") -> FastAPI: allow_methods=app_config.app.cors.allow_methods, allow_headers=app_config.app.cors.allow_headers, ) + application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5) application.state.config = app_config exception_mapper = _get_exception_mapper(app_config.app.debug) metrics = init_metrics() + metrics_dep.init_dispencer(application, metrics) urls_mapper = URLsMapper(app_config.observability.prometheus.urls_mapping) application.add_middleware( diff --git a/{{project_slug}}/handlers/debug.py.jinja b/{{project_slug}}/handlers/debug.py.jinja index 62ae72f..cb34519 100644 --- a/{{project_slug}}/handlers/debug.py.jinja +++ b/{{project_slug}}/handlers/debug.py.jinja @@ -4,11 +4,12 @@ import asyncio from typing import Literal import aiohttp -from fastapi import HTTPException +from fastapi import HTTPException, Request from fastapi.responses import JSONResponse from opentelemetry import trace from starlette import status +from {{project_slug}}.dependencies import auth_dep from {{project_slug}}.schemas import PingResponse from {{project_slug}}.utils.observability import get_span_headers @@ -64,9 +65,7 @@ async def get_exception(error_type: Literal["RuntimeError", "DebugException", "D status_code=status.HTTP_200_OK, ) async def tracing_check(): - """ - Add event to span, and sleep 2 seconds inside an inner span. - """ + """Add event to span, and sleep 2 seconds inside an inner span.""" span = trace.get_current_span() span.add_event("successful log entry", attributes={"parameters": "go here"}) @@ -83,9 +82,7 @@ async def tracing_check(): status_code=status.HTTP_200_OK, ) async def inner_get(host: str = "http://127.0.0.1:8080"): - """ - Perform GET request with span proxying to get more complicated trace. - """ + """Perform GET request with span proxying to get more complicated trace.""" def perform_get(session: aiohttp.ClientSession) -> asyncio.Task: return asyncio.create_task( @@ -112,3 +109,15 @@ async def inner_get(host: str = "http://127.0.0.1:8080"): ) return JSONResponse({"inner_results": inner_results}) + + +@debug_errors_router.get( + "/authentication_info", + status_code=status.HTTP_200_OK, +) +async def authentication_info(request: Request): + """Check authentication data from `Authorization` and `X-API-Key` headers.""" + + auth = auth_dep.obtain(request) + + return JSONResponse({"auth_data": {"x-api-key": auth.api_key, "jwt_payload": auth.jwt_payload}}) diff --git a/{{project_slug}}/middlewares/observability.py.jinja b/{{project_slug}}/middlewares/observability.py.jinja index 296d13a..2218c50 100644 --- a/{{project_slug}}/middlewares/observability.py.jinja +++ b/{{project_slug}}/middlewares/observability.py.jinja @@ -41,7 +41,7 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu def __init__(self, app: FastAPI, exception_mapper: ExceptionMapper, metrics: Metrics, urls_mapper: URLsMapper): super().__init__(app) self._exception_mapper = exception_mapper - self._metrics = metrics + self._http_metrics = metrics.http self._urls_mapper = urls_mapper async def dispatch(self, request: Request, call_next): @@ -49,7 +49,7 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu _try_get_parent_span_id(request) with _tracer.start_as_current_span("http-request") as span: trace_id = hex(span.get_span_context().trace_id or randint(1, 1 << 63))[2:] - span_id = span.get_span_context().span_id or randint(1, 1 << 32) + span_id = span.get_span_context().span_id or randint(1, 1 << 31) span.set_attributes( { http_attributes.HTTP_REQUEST_METHOD: request.method, @@ -70,7 +70,7 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu ) path_for_metric = self._urls_mapper.map(request.url.path) - self._metrics.requests_started.add(1, {"method": request.method, "path": path_for_metric}) + self._http_metrics.requests_started.add(1, {"method": request.method, "path": path_for_metric}) time_begin = time.monotonic() try: @@ -94,7 +94,7 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu ) raise ObservableException(trace_id=trace_id, span_id=span_id) from exc finally: - self._metrics.request_processing_duration.record( + self._http_metrics.request_processing_duration.record( duration_seconds, {"method": request.method, "path": path_for_metric} ) @@ -109,7 +109,7 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu duration_seconds: float, ) -> None: await logger.ainfo("request handled successfully", time_consumed=round(duration_seconds, 3)) - self._metrics.requests_finished.add( + self._http_metrics.requests_finished.add( 1, {"method": request.method, "path": path_for_metric, "status_code": result.status_code} ) @@ -132,7 +132,7 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu if exc.__cause__ is not None: cause = exc.__cause__ - self._metrics.errors.add( + self._http_metrics.errors.add( 1, { "method": request.method, diff --git a/{{project_slug}}/observability/metrics.py b/{{project_slug}}/observability/metrics.py index 50eac14..f2db9ec 100644 --- a/{{project_slug}}/observability/metrics.py +++ b/{{project_slug}}/observability/metrics.py @@ -7,7 +7,7 @@ from opentelemetry.sdk.metrics import Counter, Histogram @dataclass -class Metrics: +class HTTPMetrics: request_processing_duration: Histogram """Processing time histogram in seconds by `["method", "path"]`.""" requests_started: Counter @@ -18,30 +18,37 @@ class Metrics: """Total errors (exceptions) counter by `["method", "path", "error_type", "status_code"]`.""" +@dataclass +class Metrics: + http: HTTPMetrics + + def init_metrics() -> Metrics: meter = metrics.get_meter("{{project_name}}") return Metrics( - request_processing_duration=meter.create_histogram( - "request_processing_duration", - "sec", - "Request processing duration time in seconds", - explicit_bucket_boundaries_advisory=[ - 0.05, - 0.2, - 0.3, - 0.7, - 1.0, - 1.5, - 2.5, - 5.0, - 10.0, - 20.0, - 40.0, - 60.0, - 120.0, - ], - ), - requests_started=meter.create_counter("requests_started_total", "1", "Total number of started requests"), - requests_finished=meter.create_counter("request_finished_total", "1", "Total number of finished requests"), - errors=meter.create_counter("request_errors_total", "1", "Total number of errors (exceptions) in requests"), + http=HTTPMetrics( + request_processing_duration=meter.create_histogram( + "request_processing_duration", + "sec", + "Request processing duration time in seconds", + explicit_bucket_boundaries_advisory=[ + 0.05, + 0.2, + 0.3, + 0.7, + 1.0, + 1.5, + 2.5, + 5.0, + 10.0, + 20.0, + 40.0, + 60.0, + 120.0, + ], + ), + requests_started=meter.create_counter("requests_started_total", "1", "Total number of started requests"), + requests_finished=meter.create_counter("request_finished_total", "1", "Total number of finished requests"), + errors=meter.create_counter("request_errors_total", "1", "Total number of errors (exceptions) in requests"), + ) )