From d9fd8af91972b039bcecd5e8b0d5938b22d583a0 Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Thu, 16 Apr 2026 21:35:05 +0200 Subject: [PATCH 1/4] Added nats integration + docker usage update. --- fastapi_template/cli.py | 14 +++++ fastapi_template/template/cookiecutter.json | 3 + .../conditional_files.json | 12 +++- .../deploy/docker-compose.dev.yml | 26 --------- .../docker-compose.yml | 43 ++++++++++++-- .../pyproject.toml | 3 + .../tests/conftest.py | 26 +++++++++ .../tests/test_nats.py | 57 +++++++++++++++++++ .../{{cookiecutter.project_name}}/__main__.py | 1 + .../services/nats/dependencies.py | 16 ++++++ .../services/nats/lifespan.py | 30 ++++++++++ .../{{cookiecutter.project_name}}/settings.py | 5 ++ .../web/api/echo/__init__.py | 2 +- .../web/api/kafka/__init__.py | 3 +- .../web/api/nats/__init__.py | 2 + .../web/api/nats/schema.py | 8 +++ .../web/api/nats/views.py | 20 +++++++ .../web/api/router.py | 7 +++ .../web/application.py | 2 - .../web/gql/context.py | 13 +++++ .../web/gql/kafka/mutation.py | 2 +- .../web/gql/nats/__init__.py | 3 + .../web/gql/nats/mutation.py | 27 +++++++++ .../web/gql/nats/schema.py | 9 +++ .../web/gql/router.py | 19 ++++++- .../web/lifespan.py | 29 +++++++++- fastapi_template/tests/conftest.py | 1 + fastapi_template/tests/test_generator.py | 35 +++++++++--- 28 files changed, 370 insertions(+), 48 deletions(-) delete mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.dev.yml create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/tests/test_nats.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/dependencies.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/lifespan.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/__init__.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/schema.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/views.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/__init__.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/mutation.py create mode 100644 fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/schema.py diff --git a/fastapi_template/cli.py b/fastapi_template/cli.py index 254e2daa..7686b1ae 100644 --- a/fastapi_template/cli.py +++ b/fastapi_template/cli.py @@ -555,6 +555,20 @@ def checker(ctx: BuilderContext) -> bool: ) ), ), + MenuEntry( + code="enable_nats", + cli_name="nats", + user_view="Add NATS support", + description=( + "{what} is a message broker.\nThis message queue is {why} and very fast.".format( + what=colored("NATS", color="green"), + why=colored( + "super flexible", + color="cyan", + ), + ) + ), + ), MenuEntry( code="gunicorn", cli_name="gunicorn", diff --git a/fastapi_template/template/cookiecutter.json b/fastapi_template/template/cookiecutter.json index dc36ad12..2f225708 100644 --- a/fastapi_template/template/cookiecutter.json +++ b/fastapi_template/template/cookiecutter.json @@ -29,6 +29,9 @@ "enable_kafka": { "type": "bool" }, + "enable_nats": { + "type": "bool" + }, "enable_loguru": { "type": "bool" }, diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json b/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json index e49e9cc8..7804c227 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json +++ b/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json @@ -12,7 +12,8 @@ "{{cookiecutter.project_name}}/web/api/dummy", "{{cookiecutter.project_name}}/web/api/echo", "{{cookiecutter.project_name}}/web/api/redis", - "{{cookiecutter.project_name}}/web/api/kafka" + "{{cookiecutter.project_name}}/web/api/kafka", + "{{cookiecutter.project_name}}/web/api/nats" ] }, "Redis": { @@ -42,6 +43,15 @@ "tests/test_kafka.py" ] }, + "Nats support": { + "enabled": "{{cookiecutter.enable_nats}}", + "resources": [ + "{{cookiecutter.project_name}}/web/api/nats", + "{{cookiecutter.project_name}}/web/gql/nats", + "{{cookiecutter.project_name}}/services/nats", + "tests/test_nats.py" + ] + }, "Database support": { "enabled": "{{cookiecutter.db_info.name != 'none'}}", "resources": [ diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.dev.yml b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.dev.yml deleted file mode 100644 index 733f1c27..00000000 --- a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.dev.yml +++ /dev/null @@ -1,26 +0,0 @@ -services: - api: - ports: - # Exposes application port. - - "8000:8000" - build: - context: . - volumes: - # Adds current directory as volume. - - .:/app/src/ - environment: - # Enables autoreload. - {{cookiecutter.project_name | upper}}_RELOAD: "True" - - {%- if cookiecutter.enable_taskiq == "True" %} - - taskiq-worker: - volumes: - # Adds current directory as volume. - - .:/app/src/ - command: - - taskiq - - worker - - {{cookiecutter.project_name}}.tkq:broker - - --reload - {%- endif %} diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/docker-compose.yml b/fastapi_template/template/{{cookiecutter.project_name}}/docker-compose.yml index ad3b8b3c..c915de23 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/docker-compose.yml +++ b/fastapi_template/template/{{cookiecutter.project_name}}/docker-compose.yml @@ -2,9 +2,13 @@ services: api: &main_app build: context: . + target: dev dockerfile: ./Dockerfile image: {{cookiecutter.project_name}}:{{"${" }}{{cookiecutter.project_name | upper }}_VERSION:-latest{{"}"}} restart: always + ports: + # Exposes application port. + - "8000:8000" env_file: - path: .env required: false @@ -23,7 +27,9 @@ services: {%- if ((cookiecutter.db_info.name != "none" and cookiecutter.db_info.name != "sqlite") or (cookiecutter.enable_redis == "True") or (cookiecutter.enable_rmq == "True") or - (cookiecutter.enable_kafka == "True")) %} + (cookiecutter.enable_kafka == "True") or + (cookiecutter.enable_nats == "True") + ) %} depends_on: {%- if cookiecutter.db_info.name != "none" %} {%- if cookiecutter.db_info.name != "sqlite" %} @@ -43,6 +49,10 @@ services: kafka: condition: service_healthy {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + nats: + condition: service_healthy + {%- endif %} {%- if cookiecutter.enable_migrations == 'True' and cookiecutter.orm != 'psycopg' %} migrator: condition: service_completed_successfully @@ -50,6 +60,7 @@ services: {%- endif %} environment: {{cookiecutter.project_name | upper }}_HOST: 0.0.0.0 + {{cookiecutter.project_name | upper}}_RELOAD: "True" {%- if cookiecutter.db_info.name != "none" %} {%- if cookiecutter.db_info.name == "sqlite" %} {{cookiecutter.project_name | upper }}_DB_FILE: /db_data/db.sqlite3 @@ -72,12 +83,16 @@ services: {{cookiecutter.project_name | upper }}_REDIS_HOST: {{cookiecutter.project_name}}-redis {%- endif %} {%- if cookiecutter.enable_kafka == "True" %} - TESTKAFKA_KAFKA_BOOTSTRAP_SERVERS: '["{{cookiecutter.project_name}}-kafka:9092"]' + {{cookiecutter.project_name | upper }}_KAFKA_BOOTSTRAP_SERVERS: '["{{cookiecutter.project_name}}-kafka:9092"]' + {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + {{cookiecutter.project_name | upper }}_NATS_HOSTS: '["nats://{{cookiecutter.project_name}}-nats:4222"]' {%- endif %} - {%- if cookiecutter.db_info.name == "sqlite" %} volumes: + - .:/app/src/ + {%- if cookiecutter.db_info.name == "sqlite" %} - {{cookiecutter.project_name}}-db-data:/db_data/ - {%- endif %} + {%- endif %} {%- if cookiecutter.enable_taskiq == "True" %} @@ -88,6 +103,7 @@ services: - taskiq - worker - {{cookiecutter.project_name}}.tkq:broker + - --reload {%- endif %} {%- if cookiecutter.db_info.name == "postgresql" %} @@ -234,6 +250,25 @@ services: {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + nats: + image: nats:2.12-alpine + hostname: "{{cookiecutter.project_name}}-nats" + command: -m 8222 -js + healthcheck: + test: + - CMD + - sh + - -c + - "wget http://localhost:8222/healthz -q -O - | xargs | grep ok || exit 1" + interval: 5s + timeout: 3s + retries: 20 + start_period: 3s + ports: + - 4222:4222 + {%- endif %} + {% if cookiecutter.db_info.name != 'none' %} volumes: diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml b/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml index ce05943e..d0c74561 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml +++ b/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml @@ -132,6 +132,9 @@ dependencies = [ {%- if cookiecutter.enable_kafka == "True" %} "aiokafka >=0.12.0,<1", {%- endif %} +{%- if cookiecutter.enable_nats == "True" %} + "natsrpy>=0.1,<1", +{%- endif %} {%- if cookiecutter.enable_taskiq == "True" %} "taskiq >=0.12.0,<1", "taskiq-fastapi >=0.3.6,<1", diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py b/fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py index 073f131f..c8dd94bd 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py @@ -32,6 +32,13 @@ {%- endif %} +{%- if cookiecutter.enable_nats == "True" %} +from natsrpy import Nats +from {{cookiecutter.project_name}}.services.nats.dependencies import get_nats +from {{cookiecutter.project_name}}.services.nats.lifespan import (init_nats, + shutdown_nats) +{%- endif %} + from {{cookiecutter.project_name}}.settings import settings from {{cookiecutter.project_name}}.web.application import get_app @@ -457,6 +464,19 @@ async def test_kafka_producer() -> AsyncGenerator[AIOKafkaProducer, None]: {%- endif %} + +{%- if cookiecutter.enable_nats == "True" %} + +@pytest.fixture +async def test_nats() -> AsyncGenerator[Nats, None]: + """Creat test nats client.""" + app_mock = Mock() + await init_nats(app_mock) + yield app_mock.state.nats + await shutdown_nats(app_mock) + +{%- endif %} + {% if cookiecutter.enable_redis == "True" -%} @pytest.fixture async def fake_redis_pool() -> AsyncGenerator[ConnectionPool, None]: @@ -491,6 +511,9 @@ def fastapi_app( {%- if cookiecutter.enable_kafka == "True" %} test_kafka_producer: AIOKafkaProducer, {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + test_nats: Nats, + {%- endif %} ) -> FastAPI: """ Fixture for creating FastAPI app. @@ -512,6 +535,9 @@ def fastapi_app( {%- if cookiecutter.enable_kafka == "True" %} application.dependency_overrides[get_kafka_producer] = lambda: test_kafka_producer {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + application.dependency_overrides[get_nats] = lambda: test_nats + {%- endif %} return application # noqa: RET504 diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/tests/test_nats.py b/fastapi_template/template/{{cookiecutter.project_name}}/tests/test_nats.py new file mode 100644 index 00000000..bc906489 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/tests/test_nats.py @@ -0,0 +1,57 @@ +import asyncio +import uuid + +from fastapi import FastAPI +from httpx import AsyncClient +from starlette import status +from {{cookiecutter.project_name}}.settings import settings +from natsrpy import Nats + + +async def test_message_publishing( + fastapi_app: FastAPI, + client: AsyncClient, + test_nats: Nats, +) -> None: + """ + Test that messages are published correctly. + + It sends message to kafka, reads it and + validates that received message has the same + value. + + :param fastapi_app: current application. + :param client: httpx client. + """ + subject = uuid.uuid4().hex + payload = uuid.uuid4().hex + + async with test_nats.subscribe(subject) as sub: + {%- if cookiecutter.api_type == 'rest' %} + url = fastapi_app.url_path_for("publish_nats_message") + response = await client.post( + url, + json={ + "subject": subject, + "message": payload, + }, + ) + {%- elif cookiecutter.api_type == 'graphql' %} + url = fastapi_app.url_path_for('handle_http_post') + response = await client.post( + url, + json={ + "query": "mutation($message:NatsMessageDTO!)" + "{publishNatsMessage(message:$message)}", + "variables": { + "message": { + "subject": subject, + "message": payload, + }, + }, + }, + ) + {%- endif %} + assert response.status_code == status.HTTP_200_OK + message = await asyncio.wait_for(anext(sub), 1.0) + assert message.payload == payload.encode() diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py index 5c0449ad..5f975cb6 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py @@ -77,6 +77,7 @@ def main() -> None: port=settings.port, reload=settings.reload, log_level=settings.log_level.value.lower(), + access_log=True, factory=True, ) {%- endif %} diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/dependencies.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/dependencies.py new file mode 100644 index 00000000..e2f9c133 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/dependencies.py @@ -0,0 +1,16 @@ +from fastapi import Request +from natsrpy import Nats + +{%- if cookiecutter.enable_taskiq == "True" %} +from taskiq import TaskiqDepends +{%- endif %} + + +def get_nats(request: Request {%- if cookiecutter.enable_taskiq == "True" %} = TaskiqDepends(){%- endif %}) -> Nats: # pragma: no cover + """ + Returns nats instance. + + :param request: current request. + :return: nats from the state. + """ + return request.app.state.nats diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/lifespan.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/lifespan.py new file mode 100644 index 00000000..bec4a9a5 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/lifespan.py @@ -0,0 +1,30 @@ +from fastapi import FastAPI +import natsrpy +from {{cookiecutter.project_name}}.settings import settings + + +async def init_nats(app: FastAPI) -> None: # pragma: no cover + """ + Initialize nats. + + This function creates nats instance + and makes initial connection to + the cluster. + + :param app: current application. + """ + app.state.nats = natsrpy.Nats(settings.nats_hosts) + await app.state.nats.startup() + + +async def shutdown_nats(app: FastAPI) -> None: # pragma: no cover + """ + Shutdown nats client. + + This function closes all connections + and sends all pending data to nats. + + :param app: current application. + """ + await app.state.nats.drain() + await app.state.nats.shutdown() diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py index 1420d219..401e5530 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/settings.py @@ -126,6 +126,11 @@ class Settings(BaseSettings): {%- endif %} + + {%- if cookiecutter.enable_nats == "True" %} + nats_hosts: list[str] = ["nats://{{cookiecutter.project_name}}-nats:4222"] + {%- endif %} + {%- if cookiecutter.db_info.name != "none" %} diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/echo/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/echo/__init__.py index 06767aff..05eac6a4 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/echo/__init__.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/echo/__init__.py @@ -1,4 +1,4 @@ """Echo API.""" -from {{cookiecutter.project_name}}.web.api.echo.views import router +from .views import router __all__ = ['router'] diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/kafka/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/kafka/__init__.py index 8b456396..7038649f 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/kafka/__init__.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/kafka/__init__.py @@ -1,4 +1,3 @@ -"""API to interact with kafka.""" -from {{cookiecutter.project_name}}.web.api.kafka.views import router +from .views import router __all__ = ["router"] diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/__init__.py new file mode 100644 index 00000000..ea1b376d --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/__init__.py @@ -0,0 +1,2 @@ +from .views import router +__all__ = ["router"] diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/schema.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/schema.py new file mode 100644 index 00000000..ff5026af --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/schema.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class NatsMessage(BaseModel): + """DTO for kafka messages.""" + + subject: str + message: str diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/views.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/views.py new file mode 100644 index 00000000..6c94cb40 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/nats/views.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, Depends +from {{cookiecutter.project_name}}.services.nats.dependencies import get_nats +from {{cookiecutter.project_name}}.web.api.nats.schema import NatsMessage +from natsrpy import Nats + +router = APIRouter() + + +@router.post("/") +async def publish_nats_message( + nats_message: NatsMessage, + nats: Nats = Depends(get_nats), +) -> None: + """ + Sends message to nats. + + :param nats: nats instance. + :param nats_message: message to publish. + """ + await nats.publish(nats_message.subject, nats_message.message) diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py index dd02c1a4..33016662 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/router.py @@ -19,6 +19,10 @@ {%- if cookiecutter.enable_rmq == "True" %} from {{cookiecutter.project_name}}.web.api import rabbit +{%- endif %} +{%- if cookiecutter.enable_nats == "True" %} +from {{cookiecutter.project_name}}.web.api import nats + {%- endif %} {%- if cookiecutter.enable_kafka == "True" %} from {{cookiecutter.project_name}}.web.api import kafka @@ -52,6 +56,9 @@ {%- if cookiecutter.enable_rmq == "True" %} api_router.include_router(rabbit.router, prefix="/rabbit", tags=["rabbit"]) {%- endif %} +{%- if cookiecutter.enable_nats == "True" %} +api_router.include_router(nats.router, prefix="/nats", tags=["nats"]) +{%- endif %} {%- if cookiecutter.enable_kafka == "True" %} api_router.include_router(kafka.router, prefix="/kafka", tags=["kafka"]) {%- endif %} diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/application.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/application.py index 5a13c49f..00e38324 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/application.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/application.py @@ -1,7 +1,6 @@ import logging from fastapi import FastAPI -from fastapi.responses import UJSONResponse from {{cookiecutter.project_name}}.settings import settings from {{cookiecutter.project_name}}.web.api.router import api_router @@ -86,7 +85,6 @@ def get_app() -> FastAPI: redoc_url="/api/redoc", {%- endif %} openapi_url="/api/openapi.json", - default_response_class=UJSONResponse, ) # Main router for the API. diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/context.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/context.py index d4a28d2f..b7d0bd6b 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/context.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/context.py @@ -22,6 +22,13 @@ {%- endif %} +{%- if cookiecutter.enable_nats == "True" %} +from natsrpy import Nats +from {{cookiecutter.project_name}}.services.nats.dependencies import get_nats + +{%- endif %} + + {%- if cookiecutter.orm == "sqlalchemy" %} from sqlalchemy.ext.asyncio import AsyncSession from {{cookiecutter.project_name}}.db.dependencies import get_db_session @@ -53,6 +60,9 @@ def __init__( {%- if cookiecutter.enable_kafka == "True" %} kafka_producer: AIOKafkaProducer = Depends(get_kafka_producer), {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + nats: Nats = Depends(get_nats), + {%- endif %} ) -> None: {%- if cookiecutter.enable_redis == "True" %} self.redis_pool = redis_pool @@ -69,6 +79,9 @@ def __init__( {%- if cookiecutter.enable_kafka == "True" %} self.kafka_producer = kafka_producer {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + self.nats = nats + {%- endif %} pass diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/kafka/mutation.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/kafka/mutation.py index d542e9b5..9f667d93 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/kafka/mutation.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/kafka/mutation.py @@ -6,7 +6,7 @@ @strawberry.type class Mutation: - """Mutation for rabbit package.""" + """Mutation for kafka package.""" @strawberry.mutation(description="Send message to Kafka") async def send_kafka_message( diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/__init__.py new file mode 100644 index 00000000..d33e3c2d --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/__init__.py @@ -0,0 +1,3 @@ +from .mutation import Mutation + +__all__ = ["Mutation"] diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/mutation.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/mutation.py new file mode 100644 index 00000000..38869f33 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/mutation.py @@ -0,0 +1,27 @@ +import strawberry +from strawberry.types import Info +from {{cookiecutter.project_name}}.web.gql.context import Context +from .schema import NatsMessageDTO + + +@strawberry.type +class Mutation: + """Mutation for nats package.""" + + @strawberry.mutation(description="Send message to Nats") + async def publish_nats_message( + self, + message: NatsMessageDTO, + info: Info[Context, None], + ) -> None: + """ + Sends a message to nats. + + :param message: message to publish. + :param info: current context. + """ + await info.context.nats.publish( + message.subject, + message.message, + ) + diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/schema.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/schema.py new file mode 100644 index 00000000..99fba81d --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/nats/schema.py @@ -0,0 +1,9 @@ +import strawberry + + +@strawberry.input +class NatsMessageDTO: + """Input type for nats mutation.""" + + subject: str + message: str diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/router.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/router.py index 7c1648d5..9f82e906 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/router.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/router.py @@ -22,6 +22,16 @@ {%- endif %} +{%- if cookiecutter.enable_nats == "True" %} +from {{cookiecutter.project_name}}.web.gql import nats + +{%- endif %} + +{%- endif %} + + +{%- if cookiecutter.otlp_enabled == "True" %} +from strawberry.extensions.tracing import OpenTelemetryExtension {%- endif %} @strawberry.type @@ -55,6 +65,9 @@ class Mutation( {%- if cookiecutter.enable_kafka == "True" %} kafka.Mutation, {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + nats.Mutation, + {%- endif %} {%- endif %} ): """Main mutation.""" @@ -63,10 +76,14 @@ class Mutation( schema = strawberry.Schema( Query, Mutation, + extensions=( + {%- if cookiecutter.otlp_enabled == "True" %} + OpenTelemetryExtension, + {%- endif %} + ) ) gql_router: GraphQLRouter[Context, None] = GraphQLRouter( schema, - graphiql=True, context_getter=get_context, ) diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/lifespan.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/lifespan.py index e0839669..be558268 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/lifespan.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/lifespan.py @@ -29,6 +29,12 @@ {%- endif %} +{%- if cookiecutter.enable_nats == "True" %} +from {{cookiecutter.project_name}}.services.nats.lifespan import (init_nats, + shutdown_nats) + +{%- endif %} + {%- if cookiecutter.enable_taskiq == "True" %} from {{cookiecutter.project_name}}.tkq import broker from taskiq.instrumentation import TaskiqInstrumentor @@ -82,6 +88,11 @@ from opentelemetry.instrumentation.logging import LoggingInstrumentor {%- endif %} + +{%- if cookiecutter.enable_nats == "True" %} +from natsrpy.instrumentation import NatsrpyInstrumentor +{%- endif %} + {%- endif %} {%- if cookiecutter.orm == "psycopg" %} @@ -252,6 +263,9 @@ def setup_opentelemetry(app: FastAPI) -> None: # pragma: no cover tracer_provider=tracer_provider, ) {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + NatsrpyInstrumentor().instrument(tracer_provider=tracer_provider) + {%- endif %} def stop_opentelemetry(app: FastAPI) -> None: # pragma: no cover @@ -279,6 +293,9 @@ def stop_opentelemetry(app: FastAPI) -> None: # pragma: no cover {%- if cookiecutter.enable_taskiq == "True" %} TaskiqInstrumentor().uninstrument_broker(broker) {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + NatsrpyInstrumentor().uninstrument() + {%- endif %} {%- endif %} @@ -308,6 +325,9 @@ async def lifespan_setup(app: FastAPI) -> AsyncGenerator[None, None]: # pragma: """ app.middleware_stack = None + {%- if cookiecutter.otlp_enabled == "True" %} + setup_opentelemetry(app) + {%- endif %} {%- if cookiecutter.enable_taskiq == "True" %} if not broker.is_worker_process: await broker.startup() @@ -324,9 +344,6 @@ async def lifespan_setup(app: FastAPI) -> AsyncGenerator[None, None]: # pragma: await _create_tables() {%- endif %} {%- endif %} - {%- if cookiecutter.otlp_enabled == "True" %} - setup_opentelemetry(app) - {%- endif %} {%- if cookiecutter.enable_redis == "True" %} init_redis(app) {%- endif %} @@ -336,6 +353,9 @@ async def lifespan_setup(app: FastAPI) -> AsyncGenerator[None, None]: # pragma: {%- if cookiecutter.enable_kafka == "True" %} await init_kafka(app) {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + await init_nats(app) + {%- endif %} {%- if cookiecutter.prometheus_enabled == "True" %} setup_prometheus(app) {%- endif %} @@ -363,6 +383,9 @@ async def lifespan_setup(app: FastAPI) -> AsyncGenerator[None, None]: # pragma: {%- if cookiecutter.enable_kafka == "True" %} await shutdown_kafka(app) {%- endif %} + {%- if cookiecutter.enable_nats == "True" %} + await shutdown_nats(app) + {%- endif %} {%- if cookiecutter.otlp_enabled == "True" %} stop_opentelemetry(app) {%- endif %} diff --git a/fastapi_template/tests/conftest.py b/fastapi_template/tests/conftest.py index 0f1a768b..2fe451b3 100644 --- a/fastapi_template/tests/conftest.py +++ b/fastapi_template/tests/conftest.py @@ -69,6 +69,7 @@ def default_context(project_name: str) -> BuilderContext: otlp_enabled=False, sentry_enabled=False, force=True, + enable_nats=False, ) diff --git a/fastapi_template/tests/test_generator.py b/fastapi_template/tests/test_generator.py index bd4f2420..d7bb43b2 100644 --- a/fastapi_template/tests/test_generator.py +++ b/fastapi_template/tests/test_generator.py @@ -55,7 +55,9 @@ def test_default_without_db(default_context: BuilderContext, worker_id: str): "piccolo", ], ) -def test_default_with_db(default_context: BuilderContext, db: str, orm: str, worker_id: str): +def test_default_with_db( + default_context: BuilderContext, db: str, orm: str, worker_id: str +): if orm == "piccolo" and db == "mysql": return run_default_check(init_context(default_context, db, orm), worker_id) @@ -73,7 +75,9 @@ def test_default_with_db(default_context: BuilderContext, db: str, orm: str, wor "beanie", ], ) -def test_default_with_nosql_db(default_context: BuilderContext, db: str, orm: str, worker_id: str): +def test_default_with_nosql_db( + default_context: BuilderContext, db: str, orm: str, worker_id: str +): run_default_check(init_context(default_context, db, orm), worker_id) @@ -87,7 +91,9 @@ def test_default_with_nosql_db(default_context: BuilderContext, db: str, orm: st "piccolo", ], ) -def test_default_for_apis(default_context: BuilderContext, orm: str, api: str, worker_id: str): +def test_default_for_apis( + default_context: BuilderContext, orm: str, api: str, worker_id: str +): run_default_check(init_context(default_context, "postgresql", orm, api), worker_id) @@ -96,9 +102,11 @@ def test_default_for_apis(default_context: BuilderContext, orm: str, api: str, w "orm", [ "beanie", - ] + ], ) -def test_default_for_apis_with_nosql_db(default_context: BuilderContext, orm: str, api: str, worker_id: str): +def test_default_for_apis_with_nosql_db( + default_context: BuilderContext, orm: str, api: str, worker_id: str +): run_default_check(init_context(default_context, "mongodb", orm, api), worker_id) @@ -149,7 +157,9 @@ def test_without_migrations(default_context: BuilderContext, orm: str, worker_id run_default_check(context, worker_id) -def test_without_migrations_with_nosql_db(default_context: BuilderContext, worker_id: str): +def test_without_migrations_with_nosql_db( + default_context: BuilderContext, worker_id: str +): context = init_context(default_context, "mongodb", "beanie") context.enable_migrations = False run_default_check(context, worker_id) @@ -210,13 +220,17 @@ def test_rmq(default_context: BuilderContext, api: str, worker_id: str): run_default_check(default_context, worker_id) -def test_telemetry_pre_commit(default_context: BuilderContext, worker_id: str): +@pytest.mark.parametrize("api", ["rest", "graphql"]) +def test_telemetry_pre_commit(default_context: BuilderContext, api: str, worker_id: str): + default_context.api_type = api default_context.enable_rmq = True default_context.enable_redis = True default_context.prometheus_enabled = True default_context.otlp_enabled = True default_context.sentry_enabled = True default_context.enable_loguru = True + default_context.enable_taskiq = True + default_context.enable_nats = True run_default_check(default_context, worker_id, without_pytest=True) @@ -230,3 +244,10 @@ def test_kafka(default_context: BuilderContext, api: str, worker_id: str): default_context.enable_kafka = True default_context.api_type = api run_default_check(default_context, worker_id) + + +@pytest.mark.parametrize("api", ["rest", "graphql"]) +def test_nats(default_context: BuilderContext, api: str, worker_id: str): + default_context.enable_nats = True + default_context.api_type = api + run_default_check(default_context, worker_id) From 1c33e7282e7066abbf01743b8e7d3585eaf2b355 Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Thu, 16 Apr 2026 21:40:00 +0200 Subject: [PATCH 2/4] Images updated. --- fastapi_template/cli.py | 4 ++-- .../template/{{cookiecutter.project_name}}/Dockerfile | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fastapi_template/cli.py b/fastapi_template/cli.py index 7686b1ae..ec9c4cab 100644 --- a/fastapi_template/cli.py +++ b/fastapi_template/cli.py @@ -156,7 +156,7 @@ def checker(ctx: BuilderContext) -> bool: ), additional_info=Database( name="mysql", - image="mysql:8.4", + image="mysql:9.6", async_driver="mysql+aiomysql", driver_short="mysql", driver="mysql", @@ -175,7 +175,7 @@ def checker(ctx: BuilderContext) -> bool: ), additional_info=Database( name="postgresql", - image="postgres:18.1-bookworm", + image="postgres:18.3-trixie", async_driver="postgresql+asyncpg", driver_short="postgres", driver="postgresql", diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/Dockerfile b/fastapi_template/template/{{cookiecutter.project_name}}/Dockerfile index 98946396..b82c379f 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/Dockerfile +++ b/fastapi_template/template/{{cookiecutter.project_name}}/Dockerfile @@ -1,10 +1,10 @@ -FROM ghcr.io/astral-sh/uv:0.9.12-bookworm AS uv +FROM ghcr.io/astral-sh/uv:0.11.7-python3.13-trixie AS uv # ----------------------------------- # STAGE 1: prod stage # Only install main dependencies # ----------------------------------- -FROM python:3.13-slim-bookworm AS prod +FROM python:3.13-slim-trixie AS prod {%- if cookiecutter.db_info.name == "mysql" %} RUN apt-get update && apt-get install -y \ From 02435a62b07dd02ae826f7c27f2ce6189bf869eb Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Thu, 16 Apr 2026 22:32:00 +0200 Subject: [PATCH 3/4] Fixed redis. --- .../{{cookiecutter.project_name}}/pyproject.toml | 3 --- .../tests/conftest.py | 14 +++++--------- .../tests/test_redis.py | 13 ++++++------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml b/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml index d0c74561..ec73e56b 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml +++ b/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml @@ -161,9 +161,6 @@ dev = [ "pytest-cov >=7.0.0,<8", "anyio >=4.11.0,<5", "pytest-env >=1.2.0,<2", -{%- if cookiecutter.enable_redis == "True" %} - "fakeredis >=2.32.1,<3", -{%- endif %} {%- if cookiecutter.orm == "tortoise" %} "asynctest >=0.13.0,<1", "nest-asyncio >=1.6.0,<2", diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py b/fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py index c8dd94bd..71ff4624 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py @@ -8,8 +8,6 @@ from httpx import AsyncClient, ASGITransport {%- if cookiecutter.enable_redis == "True" %} -from fakeredis import FakeServer -from fakeredis.aioredis import FakeConnection from redis.asyncio import ConnectionPool from {{cookiecutter.project_name}}.services.redis.dependency import get_redis_pool @@ -479,15 +477,13 @@ async def test_nats() -> AsyncGenerator[Nats, None]: {% if cookiecutter.enable_redis == "True" -%} @pytest.fixture -async def fake_redis_pool() -> AsyncGenerator[ConnectionPool, None]: +async def test_redis_pool() -> AsyncGenerator[ConnectionPool, None]: """ Get instance of a fake redis. - :yield: FakeRedis instance. + :yield: ConnectionPool instance. """ - server = FakeServer() - server.connected = True - pool = ConnectionPool(connection_class=FakeConnection, server=server) + pool = ConnectionPool.from_url(str(settings.redis_url)) yield pool @@ -503,7 +499,7 @@ def fastapi_app( dbpool: AsyncConnectionPool[Any], {%- endif %} {% if cookiecutter.enable_redis == "True" -%} - fake_redis_pool: ConnectionPool, + test_redis_pool: ConnectionPool, {%- endif %} {%- if cookiecutter.enable_rmq == 'True' %} test_rmq_pool: Pool[Channel], @@ -527,7 +523,7 @@ def fastapi_app( application.dependency_overrides[get_db_pool] = lambda: dbpool {%- endif %} {%- if cookiecutter.enable_redis == "True" %} - application.dependency_overrides[get_redis_pool] = lambda: fake_redis_pool + application.dependency_overrides[get_redis_pool] = lambda: test_redis_pool {%- endif %} {%- if cookiecutter.enable_rmq == 'True' %} application.dependency_overrides[get_rmq_channel_pool] = lambda: test_rmq_pool diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/tests/test_redis.py b/fastapi_template/template/{{cookiecutter.project_name}}/tests/test_redis.py index 7d2af6a8..9b9a0786 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/tests/test_redis.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/tests/test_redis.py @@ -1,6 +1,5 @@ import uuid -import fakeredis from fastapi import FastAPI from httpx import AsyncClient from redis.asyncio import ConnectionPool, Redis @@ -9,14 +8,14 @@ async def test_setting_value( fastapi_app: FastAPI, - fake_redis_pool: ConnectionPool, + test_redis_pool: ConnectionPool, client: AsyncClient, ) -> None: """ Tests that you can set value in redis. :param fastapi_app: current application fixture. - :param fake_redis_pool: fake redis pool. + :param test_redis_pool: fake redis pool. :param client: client fixture. """ {%- if cookiecutter.api_type == 'rest' %} @@ -51,26 +50,26 @@ async def test_setting_value( {%- endif %} assert response.status_code == status.HTTP_200_OK - async with Redis(connection_pool=fake_redis_pool) as redis: + async with Redis(connection_pool=test_redis_pool) as redis: actual_value = await redis.get(test_key) assert actual_value.decode() == test_val async def test_getting_value( fastapi_app: FastAPI, - fake_redis_pool: ConnectionPool, + test_redis_pool: ConnectionPool, client: AsyncClient, ) -> None: """ Tests that you can get value from redis by key. :param fastapi_app: current application fixture. - :param fake_redis_pool: fake redis pool. + :param test_redis_pool: fake redis pool. :param client: client fixture. """ test_key = uuid.uuid4().hex test_val = uuid.uuid4().hex - async with Redis(connection_pool=fake_redis_pool) as redis: + async with Redis(connection_pool=test_redis_pool) as redis: await redis.set(test_key, test_val) {%- if cookiecutter.api_type == 'rest' %} From 803861cae9e4eac4dc8a68b2c18fb5a7fbb58f7e Mon Sep 17 00:00:00 2001 From: Pavel Kirilin Date: Thu, 16 Apr 2026 22:35:19 +0200 Subject: [PATCH 4/4] Fixed NATS. --- .../{{cookiecutter.project_name}}/services/nats/lifespan.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/lifespan.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/lifespan.py index bec4a9a5..8f2012a3 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/lifespan.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/nats/lifespan.py @@ -26,5 +26,4 @@ async def shutdown_nats(app: FastAPI) -> None: # pragma: no cover :param app: current application. """ - await app.state.nats.drain() await app.state.nats.shutdown()