main
Сергей Ванюшкин 2024-04-16 04:48:10 +03:00
parent 9b4184994f
commit 903c216966
17 changed files with 363 additions and 33 deletions

View File

@ -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) [![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`
- Для остановки запущенных контейнеров
`<CTRL>-c`
`docker-compose -f compose-app.yml down`
or
`docker-compose -f compose-tests.yml down`

View File

@ -17,8 +17,12 @@ services:
app: app:
container_name: flask_demo container_name: flask_demo
environment:
REDISURL: "redis://redis:6379/0"
build: build:
context: . context: .
dockerfile: ./docker/app/Dockerfile
ports: ports:
- 8080:5000 - 8080:5000
@ -27,9 +31,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
restart: always
volumes: volumes:
- ./flask_demo_api:/usr/src/flask-demo-api/flask_demo_api - ./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'

40
compose-tests.yml Normal file
View File

@ -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'

View File

@ -18,4 +18,4 @@ COPY ./poetry.lock .
COPY ./pyproject.toml . COPY ./pyproject.toml .
RUN poetry install RUN poetry install --without dev

23
docker/tests/Dockerfile Normal file
View File

@ -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

View File

@ -1,3 +1,5 @@
import os
import redis # type: ignore import redis # type: ignore
from dishka import Container, Provider, Scope, make_container, provide from dishka import Container, Provider, Scope, make_container, provide
@ -14,7 +16,8 @@ from flask_demo_api.usecase.put import PutKey
class RedisSettingsProvider(Provider): class RedisSettingsProvider(Provider):
@provide(scope=Scope.APP) @provide(scope=Scope.APP)
def redis_settings(self) -> RedisSettings: def redis_settings(self) -> RedisSettings:
return RedisSettings() url = os.getenv("REDISURL") or "redis://localhost:6379/0"
return RedisSettings(url=url)
class SettingsProvider(Provider): class SettingsProvider(Provider):

View File

@ -1,8 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True) @dataclass(frozen=True)
class KeyDTO: class KeyDTO:
key: str key: str
val: Any | None = None val: str = ""

View File

@ -10,11 +10,11 @@ class Repository(Protocol):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
def add_key(self, obj: KeyDTO) -> KeyDTO: def add_key(self, obj: KeyDTO) -> KeyDTO | None:
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
def put_key(self, obj: KeyDTO) -> KeyDTO: def put_key(self, obj: KeyDTO) -> KeyDTO | None:
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod

View File

@ -3,4 +3,4 @@ from dataclasses import dataclass
@dataclass(frozen=True) @dataclass(frozen=True)
class RedisSettings: class RedisSettings:
url: str = "redis://localhost:6379/0" url: str

View File

@ -10,19 +10,36 @@ class RedisRepository(Repository):
def __init__(self, redis_pool: redis.Redis) -> None: def __init__(self, redis_pool: redis.Redis) -> None:
self.pool = redis_pool 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: def get_key(self, obj: KeyDTO) -> KeyDTO | None:
data = self.pool.get(obj.key) data = self.__get(obj.key)
if not data: if not data:
return None return None
return KeyDTO(key=str(obj.key), val=data) return KeyDTO(key=obj.key, val=data)
def add_key(self, obj: KeyDTO) -> KeyDTO: def add_key(self, obj: KeyDTO) -> KeyDTO | None:
self.pool.set(str(obj.key), pickle.dumps(obj.val)) data = self.__get(obj.key)
if data:
return None
self.__set(obj.key, obj.val)
return obj return obj
def put_key(self, obj: KeyDTO) -> KeyDTO: def put_key(self, obj: KeyDTO) -> KeyDTO | None:
return KeyDTO(key="5", val="6") 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: def delete_key(self, obj: KeyDTO) -> None:
self.pool.delete(str(obj.key)) self.pool.delete(obj.key)
return None return None

View File

@ -15,16 +15,18 @@ key_bp = Blueprint("key_bp", __name__)
def past_key(usecase: FromDishka[PostKey]): def past_key(usecase: FromDishka[PostKey]):
json_data = request.get_json() 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: if json_data:
result = usecase( result = usecase(request=KeyDTO(key=json_data.get("key"), val=json_data.get("val")))
request=KeyDTO(key=json_data.get("key"), val=json_data.get("val")) if result is None:
) abort(400, "Key alredy exist")
return ( return jsonify({"message": "Ok", "data": result}), 201
jsonify({"message": "Ok", "data": result}),
201,
)
else: else:
return jsonify({"message": "No JSON data received"}), 400 abort(400, "Invalid json data")
@key_bp.route("/", methods=["GET"]) @key_bp.route("/", methods=["GET"])
@ -50,16 +52,21 @@ def get_key(usecase: FromDishka[GetKey]):
def put_key(usecase: FromDishka[PutKey]): def put_key(usecase: FromDishka[PutKey]):
json_data = request.get_json() 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: if json_data:
result = usecase( result = usecase(request=KeyDTO(key=json_data.get("key"), val=json_data.get("val")))
request=KeyDTO(key=json_data.get("key"), val=json_data.get("val")) if result is None:
) abort(400, "No item for update")
return ( return (
jsonify({"message": "Updated", "data": result}), jsonify({"message": "Updated", "data": result}),
200, 200,
) )
else: else:
return jsonify({"message": "No JSON data received"}), 400 abort(400, "Inalid JSON data")
@key_bp.route("/", methods=["DELETE"]) @key_bp.route("/", methods=["DELETE"])
@ -74,4 +81,4 @@ def delete_key(usecase: FromDishka[DelKey]):
200, 200,
) )
else: else:
return jsonify({"message": "No JSON data received"}), 400 abort(400, "Inalid JSON data")

View File

@ -9,5 +9,5 @@ class PostKey:
) -> None: ) -> None:
self.__repository = repository self.__repository = repository
def __call__(self, request: KeyDTO) -> KeyDTO: def __call__(self, request: KeyDTO) -> KeyDTO | None:
return self.__repository.add_key(obj=request) return self.__repository.add_key(obj=request)

View File

@ -9,5 +9,5 @@ class PutKey:
) -> None: ) -> None:
self.__repository = repository self.__repository = repository
def __call__(self, request: KeyDTO) -> KeyDTO: def __call__(self, request: KeyDTO) -> KeyDTO | None:
return self.__repository.put_key(obj=request) return self.__repository.put_key(obj=request)

View File

@ -1,7 +1,6 @@
import pytest import pytest
from dishka.integrations.flask import setup_dishka from dishka.integrations.flask import setup_dishka
from flask import Flask from flask import Flask
from httpx import AsyncClient
from flask_demo_api.ioc import create_container from flask_demo_api.ioc import create_container
from flask_demo_api.main import app_factory from flask_demo_api.main import app_factory

View File

@ -1,3 +1,6 @@
import json
def test_get_without_args(client): def test_get_without_args(client):
response = client.get("/") response = client.get("/")
assert response.status_code == 400 assert response.status_code == 400
@ -12,3 +15,26 @@ def test_get_with_wrong_key(client):
assert response.get_json() == { assert response.get_json() == {
"error": "404 Not Found: Key not found", "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",
}

View File

@ -14,6 +14,11 @@ def test_post_with_empty_data(client):
def test_post_with_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"}) data = json.dumps({"key": "abc", "val": "def"})
response = client.post("/", data=data, content_type="application/json") response = client.post("/", data=data, content_type="application/json")
assert response.status_code == 201 assert response.status_code == 201
@ -21,3 +26,72 @@ def test_post_with_data(client):
"message": "Ok", "message": "Ok",
"data": {"key": "abc", "val": "def"}, "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",
}

92
tests/test_put.py Normal file
View File

@ -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",
}