Skip to content

Commit fed86a3

Browse files
authored
Added nats integration + docker image updates. (#248)
1 parent 7120a15 commit fed86a3

30 files changed

Lines changed: 384 additions & 71 deletions

fastapi_template/cli.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def checker(ctx: BuilderContext) -> bool:
156156
),
157157
additional_info=Database(
158158
name="mysql",
159-
image="mysql:8.4",
159+
image="mysql:9.6",
160160
async_driver="mysql+aiomysql",
161161
driver_short="mysql",
162162
driver="mysql",
@@ -175,7 +175,7 @@ def checker(ctx: BuilderContext) -> bool:
175175
),
176176
additional_info=Database(
177177
name="postgresql",
178-
image="postgres:18.1-bookworm",
178+
image="postgres:18.3-trixie",
179179
async_driver="postgresql+asyncpg",
180180
driver_short="postgres",
181181
driver="postgresql",
@@ -555,6 +555,20 @@ def checker(ctx: BuilderContext) -> bool:
555555
)
556556
),
557557
),
558+
MenuEntry(
559+
code="enable_nats",
560+
cli_name="nats",
561+
user_view="Add NATS support",
562+
description=(
563+
"{what} is a message broker.\nThis message queue is {why} and very fast.".format(
564+
what=colored("NATS", color="green"),
565+
why=colored(
566+
"super flexible",
567+
color="cyan",
568+
),
569+
)
570+
),
571+
),
558572
MenuEntry(
559573
code="gunicorn",
560574
cli_name="gunicorn",

fastapi_template/template/cookiecutter.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
"enable_kafka": {
3030
"type": "bool"
3131
},
32+
"enable_nats": {
33+
"type": "bool"
34+
},
3235
"enable_loguru": {
3336
"type": "bool"
3437
},

fastapi_template/template/{{cookiecutter.project_name}}/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
FROM ghcr.io/astral-sh/uv:0.9.12-bookworm AS uv
1+
FROM ghcr.io/astral-sh/uv:0.11.7-python3.13-trixie AS uv
22

33
# -----------------------------------
44
# STAGE 1: prod stage
55
# Only install main dependencies
66
# -----------------------------------
7-
FROM python:3.13-slim-bookworm AS prod
7+
FROM python:3.13-slim-trixie AS prod
88

99
{%- if cookiecutter.db_info.name == "mysql" %}
1010
RUN apt-get update && apt-get install -y \

fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"{{cookiecutter.project_name}}/web/api/dummy",
1313
"{{cookiecutter.project_name}}/web/api/echo",
1414
"{{cookiecutter.project_name}}/web/api/redis",
15-
"{{cookiecutter.project_name}}/web/api/kafka"
15+
"{{cookiecutter.project_name}}/web/api/kafka",
16+
"{{cookiecutter.project_name}}/web/api/nats"
1617
]
1718
},
1819
"Redis": {
@@ -42,6 +43,15 @@
4243
"tests/test_kafka.py"
4344
]
4445
},
46+
"Nats support": {
47+
"enabled": "{{cookiecutter.enable_nats}}",
48+
"resources": [
49+
"{{cookiecutter.project_name}}/web/api/nats",
50+
"{{cookiecutter.project_name}}/web/gql/nats",
51+
"{{cookiecutter.project_name}}/services/nats",
52+
"tests/test_nats.py"
53+
]
54+
},
4555
"Database support": {
4656
"enabled": "{{cookiecutter.db_info.name != 'none'}}",
4757
"resources": [

fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.dev.yml

Lines changed: 0 additions & 26 deletions
This file was deleted.

fastapi_template/template/{{cookiecutter.project_name}}/docker-compose.yml

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ services:
22
api: &main_app
33
build:
44
context: .
5+
target: dev
56
dockerfile: ./Dockerfile
67
image: {{cookiecutter.project_name}}:{{"${" }}{{cookiecutter.project_name | upper }}_VERSION:-latest{{"}"}}
78
restart: always
9+
ports:
10+
# Exposes application port.
11+
- "8000:8000"
812
env_file:
913
- path: .env
1014
required: false
@@ -23,7 +27,9 @@ services:
2327
{%- if ((cookiecutter.db_info.name != "none" and cookiecutter.db_info.name != "sqlite") or
2428
(cookiecutter.enable_redis == "True") or
2529
(cookiecutter.enable_rmq == "True") or
26-
(cookiecutter.enable_kafka == "True")) %}
30+
(cookiecutter.enable_kafka == "True") or
31+
(cookiecutter.enable_nats == "True")
32+
) %}
2733
depends_on:
2834
{%- if cookiecutter.db_info.name != "none" %}
2935
{%- if cookiecutter.db_info.name != "sqlite" %}
@@ -43,13 +49,18 @@ services:
4349
kafka:
4450
condition: service_healthy
4551
{%- endif %}
52+
{%- if cookiecutter.enable_nats == "True" %}
53+
nats:
54+
condition: service_healthy
55+
{%- endif %}
4656
{%- if cookiecutter.enable_migrations == 'True' and cookiecutter.orm != 'psycopg' %}
4757
migrator:
4858
condition: service_completed_successfully
4959
{%- endif %}
5060
{%- endif %}
5161
environment:
5262
{{cookiecutter.project_name | upper }}_HOST: 0.0.0.0
63+
{{cookiecutter.project_name | upper}}_RELOAD: "True"
5364
{%- if cookiecutter.db_info.name != "none" %}
5465
{%- if cookiecutter.db_info.name == "sqlite" %}
5566
{{cookiecutter.project_name | upper }}_DB_FILE: /db_data/db.sqlite3
@@ -72,12 +83,16 @@ services:
7283
{{cookiecutter.project_name | upper }}_REDIS_HOST: {{cookiecutter.project_name}}-redis
7384
{%- endif %}
7485
{%- if cookiecutter.enable_kafka == "True" %}
75-
TESTKAFKA_KAFKA_BOOTSTRAP_SERVERS: '["{{cookiecutter.project_name}}-kafka:9092"]'
86+
{{cookiecutter.project_name | upper }}_KAFKA_BOOTSTRAP_SERVERS: '["{{cookiecutter.project_name}}-kafka:9092"]'
87+
{%- endif %}
88+
{%- if cookiecutter.enable_nats == "True" %}
89+
{{cookiecutter.project_name | upper }}_NATS_HOSTS: '["nats://{{cookiecutter.project_name}}-nats:4222"]'
7690
{%- endif %}
77-
{%- if cookiecutter.db_info.name == "sqlite" %}
7891
volumes:
92+
- .:/app/src/
93+
{%- if cookiecutter.db_info.name == "sqlite" %}
7994
- {{cookiecutter.project_name}}-db-data:/db_data/
80-
{%- endif %}
95+
{%- endif %}
8196

8297
{%- if cookiecutter.enable_taskiq == "True" %}
8398

@@ -88,6 +103,7 @@ services:
88103
- taskiq
89104
- worker
90105
- {{cookiecutter.project_name}}.tkq:broker
106+
- --reload
91107
{%- endif %}
92108

93109
{%- if cookiecutter.db_info.name == "postgresql" %}
@@ -234,6 +250,25 @@ services:
234250

235251
{%- endif %}
236252

253+
{%- if cookiecutter.enable_nats == "True" %}
254+
nats:
255+
image: nats:2.12-alpine
256+
hostname: "{{cookiecutter.project_name}}-nats"
257+
command: -m 8222 -js
258+
healthcheck:
259+
test:
260+
- CMD
261+
- sh
262+
- -c
263+
- "wget http://localhost:8222/healthz -q -O - | xargs | grep ok || exit 1"
264+
interval: 5s
265+
timeout: 3s
266+
retries: 20
267+
start_period: 3s
268+
ports:
269+
- 4222:4222
270+
{%- endif %}
271+
237272
{% if cookiecutter.db_info.name != 'none' %}
238273

239274
volumes:

fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,9 @@ dependencies = [
132132
{%- if cookiecutter.enable_kafka == "True" %}
133133
"aiokafka >=0.12.0,<1",
134134
{%- endif %}
135+
{%- if cookiecutter.enable_nats == "True" %}
136+
"natsrpy>=0.1,<1",
137+
{%- endif %}
135138
{%- if cookiecutter.enable_taskiq == "True" %}
136139
"taskiq >=0.12.0,<1",
137140
"taskiq-fastapi >=0.3.6,<1",
@@ -158,9 +161,6 @@ dev = [
158161
"pytest-cov >=7.0.0,<8",
159162
"anyio >=4.11.0,<5",
160163
"pytest-env >=1.2.0,<2",
161-
{%- if cookiecutter.enable_redis == "True" %}
162-
"fakeredis >=2.32.1,<3",
163-
{%- endif %}
164164
{%- if cookiecutter.orm == "tortoise" %}
165165
"asynctest >=0.13.0,<1",
166166
"nest-asyncio >=1.6.0,<2",

fastapi_template/template/{{cookiecutter.project_name}}/tests/conftest.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
from httpx import AsyncClient, ASGITransport
99

1010
{%- if cookiecutter.enable_redis == "True" %}
11-
from fakeredis import FakeServer
12-
from fakeredis.aioredis import FakeConnection
1311
from redis.asyncio import ConnectionPool
1412
from {{cookiecutter.project_name}}.services.redis.dependency import get_redis_pool
1513

@@ -32,6 +30,13 @@
3230

3331
{%- endif %}
3432

33+
{%- if cookiecutter.enable_nats == "True" %}
34+
from natsrpy import Nats
35+
from {{cookiecutter.project_name}}.services.nats.dependencies import get_nats
36+
from {{cookiecutter.project_name}}.services.nats.lifespan import (init_nats,
37+
shutdown_nats)
38+
{%- endif %}
39+
3540
from {{cookiecutter.project_name}}.settings import settings
3641
from {{cookiecutter.project_name}}.web.application import get_app
3742

@@ -457,17 +462,28 @@ async def test_kafka_producer() -> AsyncGenerator[AIOKafkaProducer, None]:
457462

458463
{%- endif %}
459464

465+
466+
{%- if cookiecutter.enable_nats == "True" %}
467+
468+
@pytest.fixture
469+
async def test_nats() -> AsyncGenerator[Nats, None]:
470+
"""Creat test nats client."""
471+
app_mock = Mock()
472+
await init_nats(app_mock)
473+
yield app_mock.state.nats
474+
await shutdown_nats(app_mock)
475+
476+
{%- endif %}
477+
460478
{% if cookiecutter.enable_redis == "True" -%}
461479
@pytest.fixture
462-
async def fake_redis_pool() -> AsyncGenerator[ConnectionPool, None]:
480+
async def test_redis_pool() -> AsyncGenerator[ConnectionPool, None]:
463481
"""
464482
Get instance of a fake redis.
465483
466-
:yield: FakeRedis instance.
484+
:yield: ConnectionPool instance.
467485
"""
468-
server = FakeServer()
469-
server.connected = True
470-
pool = ConnectionPool(connection_class=FakeConnection, server=server)
486+
pool = ConnectionPool.from_url(str(settings.redis_url))
471487

472488
yield pool
473489

@@ -483,14 +499,17 @@ def fastapi_app(
483499
dbpool: AsyncConnectionPool[Any],
484500
{%- endif %}
485501
{% if cookiecutter.enable_redis == "True" -%}
486-
fake_redis_pool: ConnectionPool,
502+
test_redis_pool: ConnectionPool,
487503
{%- endif %}
488504
{%- if cookiecutter.enable_rmq == 'True' %}
489505
test_rmq_pool: Pool[Channel],
490506
{%- endif %}
491507
{%- if cookiecutter.enable_kafka == "True" %}
492508
test_kafka_producer: AIOKafkaProducer,
493509
{%- endif %}
510+
{%- if cookiecutter.enable_nats == "True" %}
511+
test_nats: Nats,
512+
{%- endif %}
494513
) -> FastAPI:
495514
"""
496515
Fixture for creating FastAPI app.
@@ -504,14 +523,17 @@ def fastapi_app(
504523
application.dependency_overrides[get_db_pool] = lambda: dbpool
505524
{%- endif %}
506525
{%- if cookiecutter.enable_redis == "True" %}
507-
application.dependency_overrides[get_redis_pool] = lambda: fake_redis_pool
526+
application.dependency_overrides[get_redis_pool] = lambda: test_redis_pool
508527
{%- endif %}
509528
{%- if cookiecutter.enable_rmq == 'True' %}
510529
application.dependency_overrides[get_rmq_channel_pool] = lambda: test_rmq_pool
511530
{%- endif %}
512531
{%- if cookiecutter.enable_kafka == "True" %}
513532
application.dependency_overrides[get_kafka_producer] = lambda: test_kafka_producer
514533
{%- endif %}
534+
{%- if cookiecutter.enable_nats == "True" %}
535+
application.dependency_overrides[get_nats] = lambda: test_nats
536+
{%- endif %}
515537
return application # noqa: RET504
516538

517539

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import asyncio
2+
import uuid
3+
4+
from fastapi import FastAPI
5+
from httpx import AsyncClient
6+
from starlette import status
7+
from {{cookiecutter.project_name}}.settings import settings
8+
from natsrpy import Nats
9+
10+
11+
async def test_message_publishing(
12+
fastapi_app: FastAPI,
13+
client: AsyncClient,
14+
test_nats: Nats,
15+
) -> None:
16+
"""
17+
Test that messages are published correctly.
18+
19+
It sends message to kafka, reads it and
20+
validates that received message has the same
21+
value.
22+
23+
:param fastapi_app: current application.
24+
:param client: httpx client.
25+
"""
26+
subject = uuid.uuid4().hex
27+
payload = uuid.uuid4().hex
28+
29+
async with test_nats.subscribe(subject) as sub:
30+
{%- if cookiecutter.api_type == 'rest' %}
31+
url = fastapi_app.url_path_for("publish_nats_message")
32+
response = await client.post(
33+
url,
34+
json={
35+
"subject": subject,
36+
"message": payload,
37+
},
38+
)
39+
{%- elif cookiecutter.api_type == 'graphql' %}
40+
url = fastapi_app.url_path_for('handle_http_post')
41+
response = await client.post(
42+
url,
43+
json={
44+
"query": "mutation($message:NatsMessageDTO!)"
45+
"{publishNatsMessage(message:$message)}",
46+
"variables": {
47+
"message": {
48+
"subject": subject,
49+
"message": payload,
50+
},
51+
},
52+
},
53+
)
54+
{%- endif %}
55+
assert response.status_code == status.HTTP_200_OK
56+
message = await asyncio.wait_for(anext(sub), 1.0)
57+
assert message.payload == payload.encode()

0 commit comments

Comments
 (0)