diff --git a/README.md b/README.md index 298e954..b7dfdc3 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Generator features: - Different ORMs support; - Optional migrations for each ORM except raw drivers; - redis support; +- rabbitmq support; - different CI\CD; - Kubernetes config generation; - Demo routers and models; @@ -71,7 +72,7 @@ usage: FastAPI template [-h] [--version] [--name PROJECT_NAME] [--api-type {rest,graphql}] [--db {none,sqlite,mysql,postgresql}] [--orm {ormar,sqlalchemy,tortoise,psycopg,piccolo}] - [--ci {none,gitlab_ci,github}] [--redis] + [--ci {none,gitlab_ci,github}] [--redis] [--rabbit] [--migrations] [--kube] [--dummy] [--routers] [--swagger] [--force] [--quite] @@ -89,13 +90,14 @@ optional arguments: ORM --ci {none,gitlab_ci,github} Choose CI support - --redis Add redis support + --redis Add Redis support + --rabbit Add RabbitMQ support --migrations Add migrations support - --kube Add kubernetes configs + --kube Add Kubernetes configs --dummy, --dummy-model Add dummy model --routers Add example routers - --swagger Enable self-hosted swagger + --swagger Enable self-hosted Swagger --force Owerrite directory if it exists - --quite Do not ask for feature during generation + --quite Do not ask for features during generation ``` diff --git a/fastapi_template/cli.py b/fastapi_template/cli.py index d5460c4..72243a5 100644 --- a/fastapi_template/cli.py +++ b/fastapi_template/cli.py @@ -82,11 +82,18 @@ def parse_args(): ) parser.add_argument( "--redis", - help="Add redis support", + help="Add Redis support", action="store_true", default=None, dest="enable_redis", ) + parser.add_argument( + "--rabbit", + help="Add RabbitMQ support", + action="store_true", + default=None, + dest="enable_rmq", + ) parser.add_argument( "--migrations", help="Add migrations support", @@ -96,7 +103,7 @@ def parse_args(): ) parser.add_argument( "--kube", - help="Add kubernetes configs", + help="Add Kubernetes configs", action="store_true", default=None, dest="enable_kube", @@ -118,7 +125,7 @@ def parse_args(): ) parser.add_argument( "--swagger", - help="Enable self-hosted swagger", + help="Enable self-hosted Swagger", action="store_true", default=None, dest="self_hosted_swagger", @@ -132,7 +139,7 @@ def parse_args(): ) parser.add_argument( "--quite", - help="Do not ask for feature during generation", + help="Do not ask for features during generation", action="store_true", default=False, dest="quite", @@ -159,6 +166,10 @@ def ask_features(current_context: BuilderContext) -> BuilderContext: "name": "self_hosted_swagger", "value": current_context.self_hosted_swagger, }, + "RabbitMQ integration": { + "name": "enable_rmq", + "value": current_context.enable_rmq, + }, } if current_context.db != DatabaseType.none: features["Migrations support"] = { diff --git a/fastapi_template/input_model.py b/fastapi_template/input_model.py index 2a2c7c1..a192054 100644 --- a/fastapi_template/input_model.py +++ b/fastapi_template/input_model.py @@ -117,6 +117,7 @@ class BuilderContext(BaseModel): enable_routers: Optional[bool] add_dummy: Optional[bool] = False self_hosted_swagger: Optional[bool] + enable_rmq: Optional[bool] force: bool = False quite: bool = False diff --git a/fastapi_template/template/cookiecutter.json b/fastapi_template/template/cookiecutter.json index 4260455..246bb52 100644 --- a/fastapi_template/template/cookiecutter.json +++ b/fastapi_template/template/cookiecutter.json @@ -14,6 +14,9 @@ "enable_redis": { "type": "bool" }, + "enable_rmq": { + "type": "bool" + }, "ci_type": { "type": "string" }, diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/.flake8 b/fastapi_template/template/{{cookiecutter.project_name}}/.flake8 index 1210213..ad09048 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/.flake8 +++ b/fastapi_template/template/{{cookiecutter.project_name}}/.flake8 @@ -82,6 +82,8 @@ per-file-ignores = WPS432, ; Missing parameter(s) in Docstring DAR101, + ; Found too many arguments + WPS211, ; all init files __init__.py: diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json b/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json index 941640b..61deb81 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json +++ b/fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json @@ -8,6 +8,7 @@ "REST API": { "enabled": "{{cookiecutter.api_type == 'rest'}}", "resources": [ + "{{cookiecutter.project_name}}/web/api/rabbit", "{{cookiecutter.project_name}}/web/api/dummy", "{{cookiecutter.project_name}}/web/api/echo", "{{cookiecutter.project_name}}/web/api/redis" @@ -23,6 +24,16 @@ "deploy/kube/redis.yml" ] }, + "RabbitMQ support": { + "enabled": "{{cookiecutter.enable_rmq}}", + "resources": [ + "{{cookiecutter.project_name}}/web/api/rabbit", + "{{cookiecutter.project_name}}/web/gql/rabbit", + "{{cookiecutter.project_name}}/services/rabbit", + "{{cookiecutter.project_name}}/tests/test_rabbit.py", + "deploy/kube/redis.yml" + ] + }, "Kubernetes": { "enabled": "{{cookiecutter.enable_kube}}", "resources": [ @@ -80,8 +91,11 @@ "enabled": "{{cookiecutter.enable_routers}}", "resources": [ "{{cookiecutter.project_name}}/web/api/echo", + "{{cookiecutter.project_name}}/web/gql/echo", "{{cookiecutter.project_name}}/web/api/dummy", + "{{cookiecutter.project_name}}/web/gql/dummy", "{{cookiecutter.project_name}}/web/api/redis", + "{{cookiecutter.project_name}}/web/gql/redis", "{{cookiecutter.project_name}}/tests/test_echo.py", "{{cookiecutter.project_name}}/tests/test_dummy.py", "{{cookiecutter.project_name}}/tests/test_redis.py" @@ -91,6 +105,7 @@ "enabled": "{{cookiecutter.add_dummy}}", "resources": [ "{{cookiecutter.project_name}}/web/api/dummy", + "{{cookiecutter.project_name}}/web/gql/dummy", "{{cookiecutter.project_name}}/db_sa/dao", "{{cookiecutter.project_name}}/db_sa/models/dummy_model.py", "{{cookiecutter.project_name}}/db_ormar/dao", @@ -170,4 +185,4 @@ "{{cookiecutter.project_name}}/db_tortoise/migrations/models/1_20210928165300_init_dummy_sqlite.sql" ] } -} +} \ No newline at end of file diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.yml b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.yml index e881b03..9c93bd5 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.yml +++ b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/docker-compose.yml @@ -10,7 +10,8 @@ services: env_file: - .env {%- if ((cookiecutter.db_info.name != "none" and cookiecutter.db_info.name != "sqlite") or - (cookiecutter.enable_redis == "True")) %} + (cookiecutter.enable_redis == "True") or + (cookiecutter.enable_rmq == "True")) %} depends_on: {%- if cookiecutter.db_info.name != "none" %} {%- if cookiecutter.db_info.name != "sqlite" %} @@ -22,6 +23,10 @@ services: redis: condition: service_healthy {%- endif %} + {%- if cookiecutter.enable_rmq == "True" %} + rmq: + condition: service_healthy + {%- endif %} {%- endif %} environment: {{cookiecutter.project_name | upper }}_HOST: 0.0.0.0 @@ -36,6 +41,9 @@ services: {{cookiecutter.project_name | upper}}_DB_BASE: {{cookiecutter.project_name}} {%- endif %} {%- endif %} + {%- if cookiecutter.enable_rmq == 'True' %} + {{cookiecutter.project_name | upper }}_RABBIT_HOST: {{cookiecutter.project_name}}-rmq + {%- endif %} {%- if cookiecutter.db_info.name == "sqlite" %} volumes: - {{cookiecutter.project_name}}-db-data:/db_data/ @@ -59,7 +67,7 @@ services: retries: 40 {%- endif %} - {% if cookiecutter.db_info.name == "mysql" -%} + {%- if cookiecutter.db_info.name == "mysql" %} db: image: {{cookiecutter.db_info.image}} hostname: {{cookiecutter.project_name}}-db @@ -78,7 +86,7 @@ services: - {{cookiecutter.project_name}}-db-data:/bitnami/mysql/data {%- endif %} - {% if cookiecutter.enable_migrations == 'True' -%} + {%- if cookiecutter.enable_migrations == 'True' %} migrator: image: {{cookiecutter.project_name}}:{{"${" }}{{cookiecutter.project_name | upper }}_VERSION:-latest{{"}"}} restart: "no" @@ -115,7 +123,7 @@ services: {%- endif %} {%- endif %} - {% if cookiecutter.enable_redis == "True" -%} + {%- if cookiecutter.enable_redis == "True" %} redis: image: bitnami/redis:6.2.5 hostname: {{cookiecutter.project_name}}-redis @@ -129,6 +137,23 @@ services: retries: 30 {%- endif %} + {%- if cookiecutter.enable_rmq == "True" %} + rmq: + image: rabbitmq:3.9.16-alpine + hostname: {{cookiecutter.project_name}}-rmq + restart: always + environment: + RABBITMQ_DEFAULT_USER: "guest" + RABBITMQ_DEFAULT_PASS: "guest" + RABBITMQ_DEFAULT_VHOST: "/" + healthcheck: + test: rabbitmq-diagnostics check_running -q + interval: 1s + timeout: 3s + retries: 30 + {%- endif %} + + {% if cookiecutter.db_info.name != 'none' %} volumes: {{cookiecutter.project_name}}-db-data: diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/app.yml b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/app.yml index 1046b78..a7ba2d9 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/app.yml +++ b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/app.yml @@ -52,8 +52,8 @@ spec: {%- endif %} resources: limits: - memory: "300Mi" - cpu: "200m" + memory: "200Mi" + cpu: "100m" ports: - containerPort: 8000 name: api-port diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/rabbit.yml b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/rabbit.yml new file mode 100644 index 0000000..5e9b771 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/rabbit.yml @@ -0,0 +1,52 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + namespace: "{{cookiecutter.kube_name}}" + name: "{{cookiecutter.kube_name}}-rmq" +spec: + selector: + matchLabels: + app: "{{cookiecutter.kube_name}}-rmq" + template: + metadata: + labels: + app: "{{cookiecutter.kube_name}}-rmq" + spec: + containers: + - name: rabbit + image: rabbitmq:3.9.16-alpine + startupProbe: + exec: + command: ["rabbitmq-diagnostics", "check_running", "-q"] + failureThreshold: 30 + periodSeconds: 5 + timeoutSeconds: 10 + env: + - name: RABBITMQ_DEFAULT_USER + value: "guest" + - name: RABBITMQ_DEFAULT_PASS + value: "guest" + - name: RABBITMQ_DEFAULT_VHOST + value: "/" + resources: + limits: + memory: "200Mi" + cpu: "250m" + ports: + - containerPort: 5672 + name: amqp +--- +apiVersion: v1 +kind: Service +metadata: + namespace: "{{cookiecutter.kube_name}}" + name: "{{cookiecutter.kube_name}}-rmq-service" +spec: + selector: + app: "{{cookiecutter.kube_name}}-rmq" + ports: + - port: 5672 + targetPort: amqp + +--- diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/redis.yml b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/redis.yml index 72fcc7d..baf2c23 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/redis.yml +++ b/fastapi_template/template/{{cookiecutter.project_name}}/deploy/kube/redis.yml @@ -2,16 +2,16 @@ apiVersion: apps/v1 kind: Deployment metadata: - namespace: {{cookiecutter.kube_name}} - name: {{cookiecutter.kube_name}}-redis + namespace: "{{cookiecutter.kube_name}}" + name: "{{cookiecutter.kube_name}}-redis" spec: selector: matchLabels: - app: {{cookiecutter.kube_name}}-redis + app: "{{cookiecutter.kube_name}}-redis" template: metadata: labels: - app: {{cookiecutter.kube_name}}-redis + app: "{{cookiecutter.kube_name}}-redis" spec: containers: - name: redis @@ -26,19 +26,19 @@ spec: value: "yes" resources: limits: - memory: "300Mi" - cpu: "200m" + memory: "50Mi" + cpu: "50m" ports: - containerPort: 6379 --- apiVersion: v1 kind: Service metadata: - namespace: {{cookiecutter.kube_name}} + namespace: "{{cookiecutter.kube_name}}" name: "{{cookiecutter.kube_name}}-redis-service" spec: selector: - app: {{cookiecutter.kube_name}}-redis + app: "{{cookiecutter.kube_name}}-redis" ports: - port: 6379 targetPort: 6379 diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml b/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml index e040246..3268d03 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml +++ b/fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml @@ -81,6 +81,9 @@ httptools = "^0.3.0" {%- if cookiecutter.api_type == "graphql" %} strawberry-graphql = { version = "^0.106.3", extras = ["fastapi"] } {%- endif %} +{%- if cookiecutter.enable_rmq == "True" %} +aio-pika = "^7.2.0" +{%- endif %} [tool.poetry.dev-dependencies] pytest = "^7.0" diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/conftest.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/conftest.py index 9cc5be9..07faae1 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/conftest.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/conftest.py @@ -6,11 +6,21 @@ import pytest from fastapi import FastAPI from httpx import AsyncClient +import uuid +from unittest.mock import Mock + {%- if cookiecutter.enable_redis == "True" %} from fakeredis.aioredis import FakeRedis from {{cookiecutter.project_name}}.services.redis.dependency import get_redis_connection {%- endif %} +{%- if cookiecutter.enable_rmq == "True" %} +from aio_pika import Channel +from aio_pika.abc import AbstractExchange, AbstractQueue +from aio_pika.pool import Pool +from {{cookiecutter.project_name}}.services.rabbit.dependencies import get_rmq_channel_pool +from {{cookiecutter.project_name}}.services.rabbit.lifetime import init_rabbit, shutdown_rabbit +{%- endif %} from {{cookiecutter.project_name}}.settings import settings from {{cookiecutter.project_name}}.web.application import get_app @@ -309,6 +319,88 @@ async def setup_db() -> AsyncGenerator[None, None]: {%- endif %} +{%- if cookiecutter.enable_rmq == 'True' %} + +@pytest.fixture +async def test_rmq_pool() -> AsyncGenerator[Channel, None]: + """ + Create rabbitMQ pool. + + :yield: channel pool. + """ + app_mock = Mock() + init_rabbit(app_mock) + yield app_mock.state.rmq_channel_pool + await shutdown_rabbit(app_mock) + + +@pytest.fixture +async def test_exchange_name() -> str: + """ + Name of an exchange to use in tests. + + :return: name of an exchange. + """ + return uuid.uuid4().hex + + +@pytest.fixture +async def test_routing_key() -> str: + """ + Name of routing key to use whild binding test queue. + + :return: key string. + """ + return uuid.uuid4().hex + + +@pytest.fixture +async def test_exchange( + test_exchange_name: str, + test_rmq_pool: Pool[Channel], +) -> AsyncGenerator[AbstractExchange, None]: + """ + Creates test exchange. + + :param test_exchange_name: name of an exchange to create. + :param test_rmq_pool: channel pool for rabbitmq. + :yield: created exchange. + """ + async with test_rmq_pool.acquire() as conn: + exchange = await conn.declare_exchange( + name=test_exchange_name, + auto_delete=True, + ) + yield exchange + + await exchange.delete(if_unused=False) + + +@pytest.fixture +async def test_queue( + test_exchange: AbstractExchange, + test_rmq_pool: Pool[Channel], + test_routing_key: str, +) -> AsyncGenerator[AbstractQueue, None]: + """ + Creates queue connected to exchange. + + :param test_exchange: exchange to bind queue to. + :param test_rmq_pool: channel pool for rabbitmq. + :param test_routing_key: routing key to use while binding. + :yield: queue binded to test exchange. + """ + async with test_rmq_pool.acquire() as conn: + queue = await conn.declare_queue(name=uuid.uuid4().hex) + await queue.bind( + exchange=test_exchange, + routing_key=test_routing_key, + ) + yield queue + + await queue.delete(if_unused=False, if_empty=False) + +{%- endif %} {% if cookiecutter.enable_redis == "True" -%} @pytest.fixture @@ -334,6 +426,9 @@ def fastapi_app( {% if cookiecutter.enable_redis == "True" -%} fake_redis: FakeRedis, {%- endif %} + {%- if cookiecutter.enable_rmq == 'True' %} + test_rmq_pool: Pool[Channel], + {%- endif %} ) -> FastAPI: """ Fixture for creating FastAPI app. @@ -347,7 +442,9 @@ def fastapi_app( {%- if cookiecutter.enable_redis == "True" %} application.dependency_overrides[get_redis_connection] = lambda: fake_redis {%- endif %} - + {%- if cookiecutter.enable_rmq == 'True' %} + application.dependency_overrides[get_rmq_channel_pool] = lambda: test_rmq_pool + {%- endif %} return application # noqa: WPS331 diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/__init__.py new file mode 100644 index 0000000..d02bfec --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/__init__.py @@ -0,0 +1 @@ +"""RabbitMQ service.""" diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/dependencies.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/dependencies.py new file mode 100644 index 0000000..c448852 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/dependencies.py @@ -0,0 +1,13 @@ +from aio_pika import Channel +from aio_pika.pool import Pool +from fastapi import Request + + +def get_rmq_channel_pool(request: Request) -> Pool[Channel]: + """ + Get channel pool from the state. + + :param request: current request. + :return: channel pool. + """ + return request.app.state.rmq_channel_pool diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/lifetime.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/lifetime.py new file mode 100644 index 0000000..f9af214 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/rabbit/lifetime.py @@ -0,0 +1,58 @@ +import aio_pika +from aio_pika.abc import AbstractChannel, AbstractRobustConnection +from aio_pika.pool import Pool +from fastapi import FastAPI + +from {{cookiecutter.project_name}}.settings import settings + + +def init_rabbit(app: FastAPI) -> None: + """ + Initialize rabbitmq pools. + + :param app: current FastAPI application. + """ + + async def get_connection() -> AbstractRobustConnection: # noqa: WPS430 + """ + Creates connection to RabbitMQ using url from settings. + + :return: async connection to RabbitMQ. + """ + return await aio_pika.connect_robust(str(settings.rabbit_url)) + + # This pool is used to open connections. + connection_pool: Pool[AbstractRobustConnection] = Pool( + get_connection, + max_size=settings.rabbit_pool_size, + ) + + async def get_channel() -> AbstractChannel: # noqa: WPS430 + """ + Open channel on connection. + + Channels are used to actually communicate with rabbitmq. + + :return: connected channel. + """ + async with connection_pool.acquire() as connection: + return await connection.channel() + + # This pool is used to open channels. + channel_pool: Pool[aio_pika.Channel] = Pool( + get_channel, + max_size=settings.rabbit_channel_pool_size, + ) + + app.state.rmq_pool = connection_pool + app.state.rmq_channel_pool = channel_pool + + +async def shutdown_rabbit(app: FastAPI) -> None: + """ + Close all connection and pools. + + :param app: current application. + """ + await app.state.rmq_channel_pool.close() + await app.state.rmq_pool.close() diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/__init__.py new file mode 100644 index 0000000..400ee9d --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/__init__.py @@ -0,0 +1 @@ +"""Redis service.""" diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/lifetime.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/lifetime.py new file mode 100644 index 0000000..2508127 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/services/redis/lifetime.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI +import aioredis +from {{cookiecutter.project_name}}.settings import settings + + +def init_redis(app: FastAPI) -> None: + """ + Creates connection pool for redis. + + :param app: current fastapi application. + """ + app.state.redis_pool = aioredis.ConnectionPool.from_url( + str(settings.redis_url), + ) + + +async def shutdown_redis(app: FastAPI) -> None: + """ + Closes redis connection pool. + + :param app: current FastAPI app. + """ + await app.state.redis_pool.disconnect() 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 5044b80..d305f64 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 @@ -37,7 +37,18 @@ class Settings(BaseSettings): redis_user: Optional[str] = None redis_pass: Optional[str] = None redis_base: Optional[int] = None - {% endif %} + {%- endif %} + + {%- if cookiecutter.enable_rmq == "True" %} + rabbit_host: str = "{{cookiecutter.project_name}}-rmq" + rabbit_port: int = 5672 + rabbit_user: str = "guest" + rabbit_pass: str = "guest" + rabbit_vhost: str = "/" + + rabbit_pool_size: int = 2 + rabbit_channel_pool_size: int = 10 + {%- endif %} {%- if cookiecutter.db_info.name != "none" %} @property @@ -97,6 +108,24 @@ def redis_url(self) -> URL: ) {%- endif %} + {%- if cookiecutter.enable_rmq == "True" %} + @property + def rabbit_url(self) -> URL: + """ + Assemble RabbitMQ URL from settings. + + :return: rabbit URL. + """ + return URL.build( + scheme="amqp", + host=self.rabbit_host, + port=self.rabbit_port, + user=self.rabbit_user, + password=self.rabbit_pass, + path=self.rabbit_vhost, + ) + {%- endif %} + class Config: env_file = ".env" env_prefix = "{{cookiecutter.project_name | upper }}_" diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/tests/test_rabbit.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/tests/test_rabbit.py new file mode 100644 index 0000000..458c18c --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/tests/test_rabbit.py @@ -0,0 +1,111 @@ +import uuid + +import pytest +from aio_pika import Channel +from aio_pika.abc import AbstractQueue +from aio_pika.exceptions import QueueEmpty +from aio_pika.pool import Pool +from fastapi import FastAPI +from httpx import AsyncClient + + +@pytest.mark.anyio +async def test_message_publishing( + fastapi_app: FastAPI, + client: AsyncClient, + test_queue: AbstractQueue, + test_exchange_name: str, + test_routing_key: str, +) -> None: + """ + Tests that message is published correctly. + + It sends message to rabbitmq and reads it + from binded queue. + """ + message_text = uuid.uuid4().hex + {%- if cookiecutter.api_type == 'rest' %} + url = fastapi_app.url_path_for("send_rabbit_message") + await client.post( + url, + json={ + "exchange_name": test_exchange_name, + "routing_key": test_routing_key, + "message": message_text, + }, + ) + {%- elif cookiecutter.api_type == 'graphql' %} + url = fastapi_app.url_path_for('handle_http_query') + await client.post( + url, + json={ + "query": "mutation($message:RabbitMessageDTO!)" + "{sendRabbitMessage(message:$message)}", + "variables": { + "message": { + "exchangeName": test_exchange_name, + "routingKey": test_routing_key, + "message": message_text, + }, + }, + }, + ) + {%- endif %} + message = await test_queue.get(timeout=1) + assert message is not None + await message.ack() + assert message.body.decode("utf-8") == message_text + + +@pytest.mark.anyio +async def test_message_wrong_exchange( + fastapi_app: FastAPI, + client: AsyncClient, + test_queue: AbstractQueue, + test_exchange_name: str, + test_routing_key: str, + test_rmq_pool: Pool[Channel], +) -> None: + """ + Tests that message can be published in undeclared exchange. + + It sends message to random queue, + tries to get message from binded queue + and checks that new exchange were created. + """ + random_exchange = uuid.uuid4().hex + assert random_exchange != test_exchange_name + message_text = uuid.uuid4().hex + {%- if cookiecutter.api_type == 'rest' %} + url = fastapi_app.url_path_for("send_rabbit_message") + await client.post( + url, + json={ + "exchange_name": random_exchange, + "routing_key": test_routing_key, + "message": message_text, + }, + ) + {%- elif cookiecutter.api_type == 'graphql' %} + url = fastapi_app.url_path_for('handle_http_query') + await client.post( + url, + json={ + "query": "mutation($message:RabbitMessageDTO!)" + "{sendRabbitMessage(message:$message)}", + "variables": { + "message": { + "exchangeName": random_exchange, + "routingKey": test_routing_key, + "message": message_text, + }, + }, + }, + ) + {%- endif %} + with pytest.raises(QueueEmpty): + await test_queue.get(timeout=1) + + async with test_rmq_pool.acquire() as conn: + exchange = await conn.get_exchange(random_exchange, ensure=True) + await exchange.delete(if_unused=False) diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/__init__.py new file mode 100644 index 0000000..a7e0cc5 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/__init__.py @@ -0,0 +1,4 @@ +"""API to interact with RabbitMQ.""" +from {{cookiecutter.project_name}}.web.api.rabbit.views import router + +__all__ = ["router"] diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/schema.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/schema.py new file mode 100644 index 0000000..36f92be --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/schema.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class RMQMessageDTO(BaseModel): + """DTO for publishing message in RabbitMQ.""" + + exchange_name: str + routing_key: str + message: str diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/views.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/views.py new file mode 100644 index 0000000..a5baa35 --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/api/rabbit/views.py @@ -0,0 +1,34 @@ +from aio_pika import Channel, Message +from aio_pika.pool import Pool +from fastapi import APIRouter, Depends + +from {{cookiecutter.project_name}}.services.rabbit.dependencies import get_rmq_channel_pool +from {{cookiecutter.project_name}}.web.api.rabbit.schema import RMQMessageDTO + +router = APIRouter() + + +@router.post("/") +async def send_rabbit_message( + message: RMQMessageDTO, + pool: Pool[Channel] = Depends(get_rmq_channel_pool), +) -> None: + """ + Post a message in a rabbitMQ's exchange. + + :param message: message to publish to rabbitmq. + :param pool: rabbitmq channel pool + """ + async with pool.acquire() as conn: + exchange = await conn.declare_exchange( + name=message.exchange_name, + auto_delete=True, + ) + await exchange.publish( + message=Message( + body=message.message.encode("utf-8"), + content_encoding="utf-8", + content_type="text/plain", + ), + routing_key=message.routing_key, + ) 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 2407f9c..f2f9f36 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 @@ -9,6 +9,9 @@ {%- if cookiecutter.enable_redis == "True" %} from {{cookiecutter.project_name}}.web.api import redis {%- endif %} +{%- if cookiecutter.enable_rmq == "True" %} +from {{cookiecutter.project_name}}.web.api import rabbit +{%- endif %} {%- endif %} {%- endif %} {%- if cookiecutter.self_hosted_swagger == "True" %} @@ -30,5 +33,8 @@ {%- if cookiecutter.enable_redis == "True" %} api_router.include_router(redis.router, prefix="/redis", tags=["redis"]) {%- endif %} +{%- if cookiecutter.enable_rmq == "True" %} +api_router.include_router(rabbit.router, prefix="/rabbit", tags=["rabbit"]) +{%- endif %} {%- endif %} {%- 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 3d7288a..aa7821f 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 @@ -5,7 +5,7 @@ {%- if cookiecutter.api_type == 'graphql' %} from {{cookiecutter.project_name}}.web.gql.router import gql_router {%- endif %} -from {{cookiecutter.project_name}}.web.lifetime import shutdown, startup +from {{cookiecutter.project_name}}.web.lifetime import register_startup_event, register_shutdown_event from importlib import metadata {%- if cookiecutter.orm == 'tortoise' %} @@ -46,8 +46,8 @@ def get_app() -> FastAPI: default_response_class=UJSONResponse, ) - app.on_event("startup")(startup(app)) - app.on_event("shutdown")(shutdown(app)) + register_startup_event(app) + register_shutdown_event(app) app.include_router(router=api_router, prefix="/api") {%- if cookiecutter.api_type == 'graphql' %} 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 aa01ab1..372a522 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 @@ -6,6 +6,13 @@ from {{cookiecutter.project_name}}.services.redis.dependency import get_redis_connection {%- endif %} +{%- if cookiecutter.enable_rmq == "True" %} +from aio_pika.pool import Pool +from aio_pika import Channel +from {{cookiecutter.project_name}}.services.rabbit.dependencies import get_rmq_channel_pool +{%- endif %} + + {%- if cookiecutter.db_info.name != 'none' %} from {{cookiecutter.project_name}}.db.dependencies import get_db_session {%- endif %} @@ -25,6 +32,9 @@ def __init__( {%- if cookiecutter.enable_redis == "True" %} redis: Redis = Depends(get_redis_connection), {%- endif %} + {%- if cookiecutter.enable_rmq == "True" %} + rabbit: Pool[Channel] = Depends(get_rmq_channel_pool), + {%- endif %} {%- if cookiecutter.orm == "sqlalchemy" %} db_connection: AsyncSession = Depends(get_db_session), {%- elif cookiecutter.orm == "psycopg" %} @@ -34,6 +44,9 @@ def __init__( {%- if cookiecutter.enable_redis == "True" %} self.redis = redis {%- endif %} + {%- if cookiecutter.enable_rmq == "True" %} + self.rabbit = rabbit + {%- endif %} {%- if cookiecutter.orm in ["sqlalchemy", "psycopg"] %} self.db_connection = db_connection {%- endif %} diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/__init__.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/__init__.py new file mode 100644 index 0000000..c4e8fff --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/__init__.py @@ -0,0 +1,4 @@ +"""Package to interact with rabbitMQ.""" +from {{cookiecutter.project_name}}.web.gql.rabbit.mutation import Mutation + +__all__ = ["Mutation"] diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/mutation.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/mutation.py new file mode 100644 index 0000000..d8740ac --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/mutation.py @@ -0,0 +1,35 @@ +import strawberry +from strawberry.types import Info + +from {{cookiecutter.project_name}}.web.gql.context import Context +from {{cookiecutter.project_name}}.web.gql.rabbit.schema import RabbitMessageDTO +from aio_pika import Message + + +@strawberry.type +class Mutation: + """Mutation for rabbit package.""" + + @strawberry.mutation(description="Send message to RabbitMQ") + async def send_rabbit_message( + self, message: RabbitMessageDTO, info: Info[Context, None] + ) -> None: + """ + Send a message in RabbitMQ. + + :param message: message to publish. + :param info: current context. + """ + async with info.context.rabbit.acquire() as conn: + exchange = await conn.declare_exchange( + name=message.exchange_name, + auto_delete=True, + ) + await exchange.publish( + message=Message( + body=message.message.encode("utf-8"), + content_encoding="utf-8", + content_type="text/plain", + ), + routing_key=message.routing_key, + ) diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/schema.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/schema.py new file mode 100644 index 0000000..e0298de --- /dev/null +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/gql/rabbit/schema.py @@ -0,0 +1,10 @@ +import strawberry + + +@strawberry.input +class RabbitMessageDTO: + """Input type for rabbit mutation.""" + + exchange_name: str + routing_key: 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 834b398..2f28ce1 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 @@ -11,6 +11,10 @@ {%- if cookiecutter.enable_redis == "True" %} from {{cookiecutter.project_name}}.web.gql import redis {%- endif %} +{%- if cookiecutter.enable_rmq == "True" %} +from {{cookiecutter.project_name}}.web.gql import rabbit +{%- endif %} + {%- endif %} @strawberry.type @@ -38,6 +42,9 @@ class Mutation( {%- if cookiecutter.enable_redis == "True" %} redis.Mutation, {%- endif %} + {%- if cookiecutter.enable_rmq == "True" %} + rabbit.Mutation, + {%- endif %} {%- endif %} ): """Main mutation.""" diff --git a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/lifetime.py b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/lifetime.py index 4d67207..2be0489 100644 --- a/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/lifetime.py +++ b/fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/web/lifetime.py @@ -5,9 +5,14 @@ from {{cookiecutter.project_name}}.settings import settings {%- if cookiecutter.enable_redis == "True" %} -import aioredis +from {{cookiecutter.project_name}}.services.redis.lifetime import init_redis, shutdown_redis {%- endif %} +{%- if cookiecutter.enable_rmq == "True" %} +from {{cookiecutter.project_name}}.services.rabbit.lifetime import init_rabbit, shutdown_rabbit +{%- endif %} + + {%- if cookiecutter.orm == "ormar" %} from {{cookiecutter.project_name}}.db.config import database {%- if cookiecutter.db_info.name != "none" and cookiecutter.enable_migrations == "False" %} @@ -69,18 +74,6 @@ def _setup_db(app: FastAPI) -> None: app.state.db_session_factory = session_factory {%- endif %} -{% if cookiecutter.enable_redis == "True" %} -def _setup_redis(app: FastAPI) -> None: - """ - Initialize redis connection. - - :param app: current FastAPI app. - """ - app.state.redis_pool = aioredis.ConnectionPool.from_url( - str(settings.redis_url), - ) -{%- endif %} - {%- if cookiecutter.enable_migrations == "False" %} {%- if cookiecutter.orm in ["ormar", "sqlalchemy"] %} async def _create_tables() -> None: @@ -100,7 +93,7 @@ async def _create_tables() -> None: {%- endif %} {%- endif %} -def startup(app: FastAPI) -> Callable[[], Awaitable[None]]: +def register_startup_event(app: FastAPI) -> Callable[[], Awaitable[None]]: """ Actions to run on application startup. @@ -111,6 +104,7 @@ def startup(app: FastAPI) -> Callable[[], Awaitable[None]]: :return: function that actually performs actions. """ + @app.on_event("startup") async def _startup() -> None: # noqa: WPS430 {%- if cookiecutter.orm == "sqlalchemy" %} _setup_db(app) @@ -125,14 +119,17 @@ async def _startup() -> None: # noqa: WPS430 {%- endif %} {%- endif %} {%- if cookiecutter.enable_redis == "True" %} - _setup_redis(app) + init_redis(app) + {%- endif %} + {%- if cookiecutter.enable_rmq == "True" %} + init_rabbit(app) {%- endif %} pass # noqa: WPS420 return _startup -def shutdown(app: FastAPI) -> Callable[[], Awaitable[None]]: +def register_shutdown_event(app: FastAPI) -> Callable[[], Awaitable[None]]: """ Actions to run on application's shutdown. @@ -140,6 +137,7 @@ def shutdown(app: FastAPI) -> Callable[[], Awaitable[None]]: :return: function that actually performs actions. """ + @app.on_event("shutdown") async def _shutdown() -> None: # noqa: WPS430 {%- if cookiecutter.orm == "sqlalchemy" %} await app.state.db_engine.dispose() @@ -149,7 +147,10 @@ async def _shutdown() -> None: # noqa: WPS430 await app.state.db_pool.close() {%- endif %} {%- if cookiecutter.enable_redis == "True" %} - await app.state.redis_pool.disconnect() + await shutdown_redis(app) + {%- endif %} + {%- if cookiecutter.enable_rmq == "True" %} + await shutdown_rabbit(app) {%- endif %} pass # noqa: WPS420 diff --git a/fastapi_template/tests/test_generator.py b/fastapi_template/tests/test_generator.py index 34e3e0e..5cbaf2c 100644 --- a/fastapi_template/tests/test_generator.py +++ b/fastapi_template/tests/test_generator.py @@ -97,3 +97,10 @@ def test_redis(default_context: BuilderContext, api: APIType): default_context.enable_redis = True default_context.api_type = api run_default_check(default_context) + + +@pytest.mark.parametrize("api", [APIType.rest, APIType.graphql]) +def test_rmq(default_context: BuilderContext, api: APIType): + default_context.enable_rmq = True + default_context.api_type = api + run_default_check(default_context) diff --git a/pyproject.toml b/pyproject.toml index dbc7f34..a9ad143 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "fastapi_template" -version = "3.3.2" +version = "3.3.3" description = "Feature-rich robust FastAPI template" authors = ["Pavel Kirilin "] packages = [{ include = "fastapi_template" }]