diff --git a/README.md b/README.md index 1246399..e64a61b 100644 --- a/README.md +++ b/README.md @@ -5,4 +5,52 @@ Demo api with Flask as backend and Redis as NoSql DB [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) + ## Задание + +Создать docker-compose.yml разворачивающий приложение на python с простой реализацией REST API. Решение должно состоять из двух контейнеров: +а) Любая NoSQL DB. + +б) Приложение на python, с использованием Flask, которое слушает на порту 8080 и принимает только методы GET, POST, PUT. + +в) Создаем значение ключ=значение, изменяем ключ=новое_значение, читаем значение ключа. + +г) Вновь созданные объекты должны создаваться, изменяться и читаться из NoSQL DB. + + +## Описание API +- GET: + требует наличие аргумента key: http://localhost:8080/?key=blablabla + +- POST, PUT & DELETE: + принимают данные запроса в json формате + { + "key": "your_key", + "val": "your_val" + } + + +## Установка + +- Клонируем репозиторий +`git clone https://git.pi3c.ru/pi3c/flask-demo-api.git` + +- Запуск апи + первый запуск после установки или обновления + `docker-compose -f compose-app.yml up --build` + + последующие запуски + `docker-compose -f compose-app.yml up` + +- Запуск тестов + первый запуск после установки или обновления + `docker-compose -f compose-tests.yml up --build` + + последующие запуски + `docker-compose -f compose-tests.yml up` + +- Для остановки запущенных контейнеров + `-c` + `docker-compose -f compose-app.yml down` + or + `docker-compose -f compose-tests.yml down` diff --git a/compose.yml b/compose-app.yml similarity index 77% rename from compose.yml rename to compose-app.yml index 01f310b..01dfa06 100644 --- a/compose.yml +++ b/compose-app.yml @@ -17,8 +17,12 @@ services: app: container_name: flask_demo + environment: + REDISURL: "redis://redis:6379/0" + build: context: . + dockerfile: ./docker/app/Dockerfile ports: - 8080:5000 @@ -27,9 +31,7 @@ services: redis: condition: service_healthy - restart: always - volumes: - ./flask_demo_api:/usr/src/flask-demo-api/flask_demo_api - command: /bin/bash -c 'poetry run python flask_demo_api/main.py' + command: /bin/bash -c 'poetry run app' diff --git a/compose-tests.yml b/compose-tests.yml new file mode 100644 index 0000000..a1f92e7 --- /dev/null +++ b/compose-tests.yml @@ -0,0 +1,40 @@ +version: "3.8" +services: + redis: + container_name: redis_tests + + image: redis:7.2.4-alpine3.19 + + ports: + - '6381:6379' + + healthcheck: + test: [ "CMD", "redis-cli","ping" ] + interval: 6s + timeout: 6s + retries: 5 + + app: + container_name: flask_tests + + environment: + REDISURL: "redis://redis:6379/0" + + build: + context: . + dockerfile: ./docker/tests/Dockerfile + + ports: + - 8080:5000 + + depends_on: + redis: + condition: service_healthy + + restart: always + + volumes: + - ./flask_demo_api:/usr/src/flask-demo-api/flask_demo_api + - ./tests:/usr/src/flask-demo-api/tests + + command: /bin/bash -c 'poetry run pytest -vv' diff --git a/Dockerfile b/docker/app/Dockerfile similarity index 90% rename from Dockerfile rename to docker/app/Dockerfile index 00b97c7..7a7c2fd 100644 --- a/Dockerfile +++ b/docker/app/Dockerfile @@ -18,4 +18,4 @@ COPY ./poetry.lock . COPY ./pyproject.toml . -RUN poetry install +RUN poetry install --without dev diff --git a/docker/tests/Dockerfile b/docker/tests/Dockerfile new file mode 100644 index 0000000..068663a --- /dev/null +++ b/docker/tests/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.10-slim + +ENV PYTHONDONTWRITEBYTECODE 1 + +ENV PYTHONUNBUFFERED 1 + +RUN pip install --upgrade pip + +RUN pip install poetry + +RUN poetry config virtualenvs.create false + +RUN mkdir -p /usr/src/flask-demo-api/flask_demo_api + +RUN mkdir -p /usr/src/flask-demo-api/tests + +WORKDIR /usr/src/flask-demo-api + +COPY ./poetry.lock . + +COPY ./pyproject.toml . + +RUN poetry install diff --git a/flask_demo_api/ioc.py b/flask_demo_api/ioc.py index 00c8fe3..f19382f 100644 --- a/flask_demo_api/ioc.py +++ b/flask_demo_api/ioc.py @@ -1,3 +1,5 @@ +import os + import redis # type: ignore from dishka import Container, Provider, Scope, make_container, provide @@ -14,7 +16,8 @@ from flask_demo_api.usecase.put import PutKey class RedisSettingsProvider(Provider): @provide(scope=Scope.APP) def redis_settings(self) -> RedisSettings: - return RedisSettings() + url = os.getenv("REDISURL") or "redis://localhost:6379/0" + return RedisSettings(url=url) class SettingsProvider(Provider): diff --git a/flask_demo_api/protocols/models.py b/flask_demo_api/protocols/models.py index 5840ab0..5b6e3e0 100644 --- a/flask_demo_api/protocols/models.py +++ b/flask_demo_api/protocols/models.py @@ -1,8 +1,7 @@ from dataclasses import dataclass -from typing import Any @dataclass(frozen=True) class KeyDTO: key: str - val: Any | None = None + val: str = "" diff --git a/flask_demo_api/protocols/repository.py b/flask_demo_api/protocols/repository.py index a08e113..b313723 100644 --- a/flask_demo_api/protocols/repository.py +++ b/flask_demo_api/protocols/repository.py @@ -10,11 +10,11 @@ class Repository(Protocol): raise NotImplementedError @abstractmethod - def add_key(self, obj: KeyDTO) -> KeyDTO: + def add_key(self, obj: KeyDTO) -> KeyDTO | None: raise NotImplementedError @abstractmethod - def put_key(self, obj: KeyDTO) -> KeyDTO: + def put_key(self, obj: KeyDTO) -> KeyDTO | None: raise NotImplementedError @abstractmethod diff --git a/flask_demo_api/repository/config.py b/flask_demo_api/repository/config.py index d7f0df1..878a5ea 100644 --- a/flask_demo_api/repository/config.py +++ b/flask_demo_api/repository/config.py @@ -3,4 +3,4 @@ from dataclasses import dataclass @dataclass(frozen=True) class RedisSettings: - url: str = "redis://localhost:6379/0" + url: str diff --git a/flask_demo_api/repository/redis.py b/flask_demo_api/repository/redis.py index 11cb393..85f092c 100644 --- a/flask_demo_api/repository/redis.py +++ b/flask_demo_api/repository/redis.py @@ -10,19 +10,36 @@ class RedisRepository(Repository): def __init__(self, redis_pool: redis.Redis) -> None: self.pool = redis_pool + def __set(self, key, val): + data = pickle.dumps(val) + self.pool.set(key, data) + + def __get(self, key): + data = self.pool.get(key) + if data is not None: + return pickle.loads(data) + return None + def get_key(self, obj: KeyDTO) -> KeyDTO | None: - data = self.pool.get(obj.key) + data = self.__get(obj.key) if not data: return None - return KeyDTO(key=str(obj.key), val=data) + return KeyDTO(key=obj.key, val=data) - def add_key(self, obj: KeyDTO) -> KeyDTO: - self.pool.set(str(obj.key), pickle.dumps(obj.val)) + def add_key(self, obj: KeyDTO) -> KeyDTO | None: + data = self.__get(obj.key) + if data: + return None + self.__set(obj.key, obj.val) return obj - def put_key(self, obj: KeyDTO) -> KeyDTO: - return KeyDTO(key="5", val="6") + def put_key(self, obj: KeyDTO) -> KeyDTO | None: + data = self.__get(obj.key) + if data is None: + return None + self.__set(obj.key, obj.val) + return obj def delete_key(self, obj: KeyDTO) -> None: - self.pool.delete(str(obj.key)) + self.pool.delete(obj.key) return None diff --git a/flask_demo_api/routers/key.py b/flask_demo_api/routers/key.py index dd0b86b..a9e5a20 100644 --- a/flask_demo_api/routers/key.py +++ b/flask_demo_api/routers/key.py @@ -15,16 +15,18 @@ key_bp = Blueprint("key_bp", __name__) def past_key(usecase: FromDishka[PostKey]): json_data = request.get_json() + if not json_data.get("key"): + abort(400, "'key' required") + elif not json_data.get("val"): + abort(400, "'val' required") + if json_data: - result = usecase( - request=KeyDTO(key=json_data.get("key"), val=json_data.get("val")) - ) - return ( - jsonify({"message": "Ok", "data": result}), - 201, - ) + result = usecase(request=KeyDTO(key=json_data.get("key"), val=json_data.get("val"))) + if result is None: + abort(400, "Key alredy exist") + return jsonify({"message": "Ok", "data": result}), 201 else: - return jsonify({"message": "No JSON data received"}), 400 + abort(400, "Invalid json data") @key_bp.route("/", methods=["GET"]) @@ -50,16 +52,21 @@ def get_key(usecase: FromDishka[GetKey]): def put_key(usecase: FromDishka[PutKey]): json_data = request.get_json() + if not json_data.get("key"): + abort(400, "'key' required") + elif not json_data.get("val"): + abort(400, "'val' required") + if json_data: - result = usecase( - request=KeyDTO(key=json_data.get("key"), val=json_data.get("val")) - ) + result = usecase(request=KeyDTO(key=json_data.get("key"), val=json_data.get("val"))) + if result is None: + abort(400, "No item for update") return ( jsonify({"message": "Updated", "data": result}), 200, ) else: - return jsonify({"message": "No JSON data received"}), 400 + abort(400, "Inalid JSON data") @key_bp.route("/", methods=["DELETE"]) @@ -74,4 +81,4 @@ def delete_key(usecase: FromDishka[DelKey]): 200, ) else: - return jsonify({"message": "No JSON data received"}), 400 + abort(400, "Inalid JSON data") diff --git a/flask_demo_api/usecase/add.py b/flask_demo_api/usecase/add.py index 765196f..ec10a27 100644 --- a/flask_demo_api/usecase/add.py +++ b/flask_demo_api/usecase/add.py @@ -9,5 +9,5 @@ class PostKey: ) -> None: self.__repository = repository - def __call__(self, request: KeyDTO) -> KeyDTO: + def __call__(self, request: KeyDTO) -> KeyDTO | None: return self.__repository.add_key(obj=request) diff --git a/flask_demo_api/usecase/put.py b/flask_demo_api/usecase/put.py index b9afa94..3faea1f 100644 --- a/flask_demo_api/usecase/put.py +++ b/flask_demo_api/usecase/put.py @@ -9,5 +9,5 @@ class PutKey: ) -> None: self.__repository = repository - def __call__(self, request: KeyDTO) -> KeyDTO: + def __call__(self, request: KeyDTO) -> KeyDTO | None: return self.__repository.put_key(obj=request) diff --git a/tests/conftest.py b/tests/conftest.py index 0f74a88..50614ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ import pytest from dishka.integrations.flask import setup_dishka from flask import Flask -from httpx import AsyncClient from flask_demo_api.ioc import create_container from flask_demo_api.main import app_factory diff --git a/tests/test_get.py b/tests/test_get.py index a2ab475..0c4909e 100644 --- a/tests/test_get.py +++ b/tests/test_get.py @@ -1,3 +1,6 @@ +import json + + def test_get_without_args(client): response = client.get("/") assert response.status_code == 400 @@ -12,3 +15,26 @@ def test_get_with_wrong_key(client): assert response.get_json() == { "error": "404 Not Found: Key not found", } + + +def test_get_with_existing_key(client): + response = client.get("/?key=abcd") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + data = json.dumps({"key": "abcd", "val": "def"}) + response = client.post("/", data=data, content_type="application/json") + assert response.status_code == 201 + + response = client.get("/?key=abcd") + assert response.status_code == 200 + assert response.get_json() == {"message": "Ok", "data": json.loads(data)} + + client.delete("/", data=data, content_type="application/json") + + response = client.get("/?key=abcd") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } diff --git a/tests/test_post.py b/tests/test_post.py index d43c53a..bb19edc 100644 --- a/tests/test_post.py +++ b/tests/test_post.py @@ -14,6 +14,11 @@ def test_post_with_empty_data(client): def test_post_with_data(client): + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } data = json.dumps({"key": "abc", "val": "def"}) response = client.post("/", data=data, content_type="application/json") assert response.status_code == 201 @@ -21,3 +26,72 @@ def test_post_with_data(client): "message": "Ok", "data": {"key": "abc", "val": "def"}, } + client.delete("/", data=data, content_type="application/json") + + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + + +def test_post_without_key(client): + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + data = json.dumps({"val": "def"}) + response = client.post("/", data=data, content_type="application/json") + assert response.status_code == 400 + assert response.get_json() == {"error": "400 Bad Request: 'key' required"} + + +def test_post_without_value(client): + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + + data = json.dumps({"key": "abc"}) + response = client.post("/", data=data, content_type="application/json") + assert response.status_code == 400 + assert response.get_json() == {"error": "400 Bad Request: 'val' required"} + + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + + +def test_post_with_data_double_adding(client): + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + + data = json.dumps({"key": "abc", "val": "def"}) + + response = client.post("/", data=data, content_type="application/json") + assert response.status_code == 201 + assert response.get_json() == { + "message": "Ok", + "data": {"key": "abc", "val": "def"}, + } + + response = client.post("/", data=data, content_type="application/json") + assert response.status_code == 400 + assert response.get_json() == { + "error": "400 Bad Request: Key alredy exist", + } + + client.delete("/", data=data, content_type="application/json") + + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } diff --git a/tests/test_put.py b/tests/test_put.py new file mode 100644 index 0000000..61a0381 --- /dev/null +++ b/tests/test_put.py @@ -0,0 +1,92 @@ +import json + + +def test_put_with_wrong_content_type(client): + data = json.dumps({}) + response = client.put("/", data=data) + assert response.status_code == 415 + + +def test_put_with_empty_data(client): + data = json.dumps({}) + response = client.put("/", data=data, content_type="application/json") + assert response.status_code == 400 + + +def test_put_with_wrong_data(client): + data = json.dumps({"key": "abc", "val": "def"}) + response = client.post("/", data=data, content_type="application/json") + + put_data = json.dumps({"key": "abcasd", "val": "def"}) + response = client.put("/", data=put_data, content_type="application/json") + + assert response.status_code == 400 + assert response.get_json() == {"error": "400 Bad Request: No item for update"} + + client.delete("/", data=data, content_type="application/json") + + response = client.get("/?key=abcasd") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + + +def test_put_with_correct_data(client): + data = json.dumps({"key": "abc", "val": "def"}) + response = client.post("/", data=data, content_type="application/json") + + put_data = json.dumps({"key": "abc", "val": "defan"}) + response = client.put("/", data=put_data, content_type="application/json") + + assert response.status_code == 200 + assert response.get_json() == { + "message": "Updated", + "data": {"key": "abc", "val": "defan"}, + } + + client.delete("/", data=data, content_type="application/json") + + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + + +def test_put_without_key(client): + data = json.dumps({"key": "abc", "val": "def"}) + response = client.post("/", data=data, content_type="application/json") + + put_data = json.dumps({"val": "defdd"}) + response = client.put("/", data=put_data, content_type="application/json") + + assert response.status_code == 400 + assert response.get_json() == {"error": "400 Bad Request: 'key' required"} + + client.delete("/", data=data, content_type="application/json") + + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + } + + +def test_put_without_value(client): + data = json.dumps({"key": "abc", "val": "def"}) + response = client.post("/", data=data, content_type="application/json") + + put_data = json.dumps({"key": "abcd"}) + response = client.put("/", data=put_data, content_type="application/json") + + assert response.status_code == 400 + assert response.get_json() == {"error": "400 Bad Request: 'val' required"} + + client.delete("/", data=data, content_type="application/json") + + response = client.get("/?key=abc") + assert response.status_code == 404 + assert response.get_json() == { + "error": "404 Not Found: Key not found", + }