Version 0.2.0
All checks were successful
Run linters on applied template / Python 3.13 lint and build (push) Successful in 54s

Changes:
- add metrics dispencer
- add basic authentication dependency
- enable GZIP middleware
- add !env() example to deploy section
- update dependencies state attribute name
This commit is contained in:
2025-11-30 16:59:25 +03:00
parent afe5d882ac
commit 34c1347402
16 changed files with 180 additions and 74 deletions

View File

@@ -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` To use a template you need to use `copier`
2. `cookiecutter -o <new_project_path>`
1. `pipx install copier`
2. `copier git+https://<this repository> <new_project_path> `

View File

@@ -27,6 +27,6 @@ vcs_type:
default: gitea default: gitea
_exclude: _exclude:
- copier.yaml - copier.yml
- .git - .git
- .gitea/workflows/validate.yaml - .gitea/workflows/validate.yaml

View File

@@ -13,7 +13,7 @@ db:
port: 5432 port: 5432
database: {{project_slug}}_db database: {{project_slug}}_db
user: postgres user: postgres
password: postgres password: "!env(DB_PASSWORD)"
pool_size: 2 pool_size: 2
logging: logging:
level: INFO level: INFO

View File

@@ -46,6 +46,7 @@ services:
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
environment: &api-environment-section environment: &api-environment-section
CONFIG_PATH: /app/config.yaml CONFIG_PATH: /app/config.yaml
DB_PASSWORD: postgres
volumes: &api-volumes-section volumes: &api-volumes-section
- ./configs/api.yaml:/app/config.yaml - ./configs/api.yaml:/app/config.yaml
depends_on: depends_on:
@@ -143,4 +144,3 @@ services:
depends_on: depends_on:
- jaeger - jaeger
logging: *json-logging logging: *json-logging

20
poetry.lock generated
View File

@@ -1632,6 +1632,24 @@ files = [
[package.dependencies] [package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" 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]] [[package]]
name = "pylint" name = "pylint"
version = "3.3.8" version = "3.3.8"
@@ -2171,4 +2189,4 @@ type = ["pytest-mypy"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">= 3.11" python-versions = ">= 3.11"
content-hash = "d3603a66f7b46bafe3258b196513b3721fad22b315429bad4e58eb754c4a2e51" content-hash = "7482a5c9526ee5e4fe0806e90bf066dedb5a0cdb122da5322d44e1a632527067"

View File

@@ -20,6 +20,7 @@ dependencies = [
"opentelemetry-semantic-conventions (>=0.59b0,<0.60)", "opentelemetry-semantic-conventions (>=0.59b0,<0.60)",
"aiohttp (>=3.13.2,<4.0.0)", "aiohttp (>=3.13.2,<4.0.0)",
"email-validator (>=2.3.0,<3.0.0)", "email-validator (>=2.3.0,<3.0.0)",
"pyjwt (>=2.10.1,<3.0.0)",
] ]
[build-system] [build-system]

View File

@@ -9,7 +9,7 @@ import click
import uvicorn import uvicorn
from dotenv import load_dotenv 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"] LogLevel = tp.Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]

View File

@@ -113,7 +113,7 @@ class {{ProjectName}}Config:
"""Generate an example of configuration.""" """Generate an example of configuration."""
res = cls( 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( db=MultipleDBsConfig(
master=DBConfig( master=DBConfig(
host="localhost", host="localhost",

View File

@@ -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

View File

@@ -7,19 +7,21 @@ from {{project_slug}}.db.connection.manager import PostgresConnectionManager
def init_dispencer(app: FastAPI, connection_manager: PostgresConnectionManager) -> None: def init_dispencer(app: FastAPI, connection_manager: PostgresConnectionManager) -> None:
"""Initialize PostgresConnectionManager dispencer at app's state.""" """Initialize PostgresConnectionManager dispencer at app's state."""
if hasattr(app.state, "postgres_connection_manager"): if hasattr(app.state, "postgres_connection_manager_dep"):
if not isinstance(app.state.postgres_connection_manager, PostgresConnectionManager): if not isinstance(app.state.postgres_connection_manager_dep, PostgresConnectionManager):
raise ValueError( raise ValueError(
"postgres_connection_manager attribute of app's state is already set" "postgres_connection_manager_dep attribute of app's state is already set"
f"with other value ({app.state.postgres_connection_manager})" f"with other value ({app.state.postgres_connection_manager_dep})"
) )
return 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.""" """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") 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

View File

@@ -1,4 +1,4 @@
"""PostgresConnectionManager dependency functions are defined here.""" """structlog BoundLogger dependency functions are defined here."""
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from structlog.stdlib import BoundLogger from structlog.stdlib import BoundLogger
@@ -7,27 +7,31 @@ from structlog.stdlib import BoundLogger
def init_dispencer(app: FastAPI, logger: BoundLogger) -> None: def init_dispencer(app: FastAPI, logger: BoundLogger) -> None:
"""Initialize BoundLogger dispencer at app's state.""" """Initialize BoundLogger dispencer at app's state."""
if hasattr(app.state, "logger"): if hasattr(app.state, "logger"):
if not isinstance(app.state.logger, BoundLogger): 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})") raise ValueError(
"logger attribute of app's state is already set" f"with other value ({app.state.logger_dep})"
)
return return
app.state.logger = logger app.state.logger_dep = logger
def attach_to_request(request: Request, logger: BoundLogger) -> None: def attach_to_request(request: Request, logger: BoundLogger) -> None:
"""Set logger for a concrete request. If request had already had a logger, replace it.""" """Set logger for a concrete request. If request had already had a logger, replace it."""
if hasattr(request.state, "logger"): if hasattr(request.state, "logger_dep"):
if not isinstance(request.state.logger, BoundLogger): if not isinstance(request.state.logger_dep, BoundLogger):
logger.warning("request.state.logger is already set with other value", value=request.state.logger) logger.warning("request.state.logger is already set with other value", value=request.state.logger_dep)
request.state.logger = logger 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.""" """Get a logger from request or app state."""
if hasattr(request.state, "logger"): if isinstance(app_or_request, Request):
logger = request.state.logger if hasattr(app_or_request.state, "logger_dep"):
if isinstance(logger, BoundLogger): logger = app_or_request.state.logger_dep
return logger if isinstance(logger, BoundLogger):
if not hasattr(request.app.state, "logger"): 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") raise ValueError("BoundLogger dispencer was not initialized at app preparation")
return request.app.state.logger return app_or_request.state.logger_dep

View File

@@ -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

View File

@@ -5,12 +5,13 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from {{project_slug}}.config import {{ProjectName}}Config from {{project_slug}}.config import {{ProjectName}}Config
from {{project_slug}}.db.connection.manager import PostgresConnectionManager 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}}.exceptions.mapper import ExceptionMapper
from {{project_slug}}.handlers.debug import DebugException, DebugExceptionWithParams from {{project_slug}}.handlers.debug import DebugException, DebugExceptionWithParams
from {{project_slug}}.middlewares.exception_handler import ExceptionHandlerMiddleware 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_methods=app_config.app.cors.allow_methods,
allow_headers=app_config.app.cors.allow_headers, allow_headers=app_config.app.cors.allow_headers,
) )
application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5)
application.state.config = app_config application.state.config = app_config
exception_mapper = _get_exception_mapper(app_config.app.debug) exception_mapper = _get_exception_mapper(app_config.app.debug)
metrics = init_metrics() metrics = init_metrics()
metrics_dep.init_dispencer(application, metrics)
urls_mapper = URLsMapper(app_config.observability.prometheus.urls_mapping) urls_mapper = URLsMapper(app_config.observability.prometheus.urls_mapping)
application.add_middleware( application.add_middleware(

View File

@@ -4,11 +4,12 @@ import asyncio
from typing import Literal from typing import Literal
import aiohttp import aiohttp
from fastapi import HTTPException from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from opentelemetry import trace from opentelemetry import trace
from starlette import status from starlette import status
from {{project_slug}}.dependencies import auth_dep
from {{project_slug}}.schemas import PingResponse from {{project_slug}}.schemas import PingResponse
from {{project_slug}}.utils.observability import get_span_headers 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, status_code=status.HTTP_200_OK,
) )
async def tracing_check(): 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 = trace.get_current_span()
span.add_event("successful log entry", attributes={"parameters": "go here"}) span.add_event("successful log entry", attributes={"parameters": "go here"})
@@ -83,9 +82,7 @@ async def tracing_check():
status_code=status.HTTP_200_OK, status_code=status.HTTP_200_OK,
) )
async def inner_get(host: str = "http://127.0.0.1:8080"): 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: def perform_get(session: aiohttp.ClientSession) -> asyncio.Task:
return asyncio.create_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}) 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}})

View File

@@ -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): def __init__(self, app: FastAPI, exception_mapper: ExceptionMapper, metrics: Metrics, urls_mapper: URLsMapper):
super().__init__(app) super().__init__(app)
self._exception_mapper = exception_mapper self._exception_mapper = exception_mapper
self._metrics = metrics self._http_metrics = metrics.http
self._urls_mapper = urls_mapper self._urls_mapper = urls_mapper
async def dispatch(self, request: Request, call_next): 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) _try_get_parent_span_id(request)
with _tracer.start_as_current_span("http-request") as span: 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:] 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( span.set_attributes(
{ {
http_attributes.HTTP_REQUEST_METHOD: request.method, 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) 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() time_begin = time.monotonic()
try: 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 raise ObservableException(trace_id=trace_id, span_id=span_id) from exc
finally: finally:
self._metrics.request_processing_duration.record( self._http_metrics.request_processing_duration.record(
duration_seconds, {"method": request.method, "path": path_for_metric} duration_seconds, {"method": request.method, "path": path_for_metric}
) )
@@ -109,7 +109,7 @@ class ObservabilityMiddleware(BaseHTTPMiddleware): # pylint: disable=too-few-pu
duration_seconds: float, duration_seconds: float,
) -> None: ) -> None:
await logger.ainfo("request handled successfully", time_consumed=round(duration_seconds, 3)) 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} 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: if exc.__cause__ is not None:
cause = exc.__cause__ cause = exc.__cause__
self._metrics.errors.add( self._http_metrics.errors.add(
1, 1,
{ {
"method": request.method, "method": request.method,

View File

@@ -7,7 +7,7 @@ from opentelemetry.sdk.metrics import Counter, Histogram
@dataclass @dataclass
class Metrics: class HTTPMetrics:
request_processing_duration: Histogram request_processing_duration: Histogram
"""Processing time histogram in seconds by `["method", "path"]`.""" """Processing time histogram in seconds by `["method", "path"]`."""
requests_started: Counter requests_started: Counter
@@ -18,30 +18,37 @@ class Metrics:
"""Total errors (exceptions) counter by `["method", "path", "error_type", "status_code"]`.""" """Total errors (exceptions) counter by `["method", "path", "error_type", "status_code"]`."""
@dataclass
class Metrics:
http: HTTPMetrics
def init_metrics() -> Metrics: def init_metrics() -> Metrics:
meter = metrics.get_meter("{{project_name}}") meter = metrics.get_meter("{{project_name}}")
return Metrics( return Metrics(
request_processing_duration=meter.create_histogram( http=HTTPMetrics(
"request_processing_duration", request_processing_duration=meter.create_histogram(
"sec", "request_processing_duration",
"Request processing duration time in seconds", "sec",
explicit_bucket_boundaries_advisory=[ "Request processing duration time in seconds",
0.05, explicit_bucket_boundaries_advisory=[
0.2, 0.05,
0.3, 0.2,
0.7, 0.3,
1.0, 0.7,
1.5, 1.0,
2.5, 1.5,
5.0, 2.5,
10.0, 5.0,
20.0, 10.0,
40.0, 20.0,
60.0, 40.0,
120.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"), requests_started=meter.create_counter("requests_started_total", "1", "Total number of started requests"),
errors=meter.create_counter("request_errors_total", "1", "Total number of errors (exceptions) in 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"),
)
) )