Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions fastapi_template/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions fastapi_template/template/cookiecutter.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"enable_kafka": {
"type": "bool"
},
"enable_nats": {
"type": "bool"
},
"enable_loguru": {
"type": "bool"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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 \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": [
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" %}
Expand All @@ -43,13 +49,18 @@ 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
{%- endif %}
{%- 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
Expand All @@ -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" %}

Expand All @@ -88,6 +103,7 @@ services:
- taskiq
- worker
- {{cookiecutter.project_name}}.tkq:broker
- --reload
{%- endif %}

{%- if cookiecutter.db_info.name == "postgresql" %}
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -158,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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -32,6 +30,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

Expand Down Expand Up @@ -457,17 +462,28 @@ 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]:
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

Expand All @@ -483,14 +499,17 @@ 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],
{%- endif %}
{%- 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.
Expand All @@ -504,14 +523,17 @@ 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
{%- endif %}
{%- 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


Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading