Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/.idea
*/__pycache__
/venv
/.pytest_cash
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,47 @@
# WebServiceDevelopment
# WebServiceDevelopment

Это репозиторий, в котором будут храниться решения ДЗ по курсу
разработки веб-сервисов на питоне. Домашними заданиями является работа
над проектом. Пока что в качестве проекта сделать "датаграм" (типо
"телеграм", но "датаграм", потому что на курсе по Java нам предлагали
различать UDP и TCP так, что UDP -- это датаграмма, а TCP -- телеграмма).

## Установка необходимых пакетов
`pip install strawberry-graphql[debug-server]` -- установка библиотеки
[strawberry](https://strawberry.rocks/).
Ещё нужно установить `fastapi` и `pydantic`, позже добавлю инструкции.

## Запуск
`uvicorn main:datagram_app --reload` -- для запуска.

http://127.0.0.1:8000/docs -- ручки

http://127.0.0.1:8000/graphql -- работа с GraphQL

## GraphQL
Пример запроса:

```
{
users (year:2001) {
name
additionalInfo {
status
birthDate {
year
day
}
}
}
}
```

Все схемы и типы указаны в файле `strawbery_types.py`

## Тестирование
Перед тестированием установите:
`pip install email-validator`
Comment on lines +42 to +43

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

посмотрите poetry - позволяет удобно разделять зависимости dev и prod


Запуск юнит тестов: `python -m pytest tests/unit_tests.py`

Запуск интеграционных тестов: `python -m pytest tests/integration_tests.py`
52 changes: 52 additions & 0 deletions databases/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from typing import Dict, Optional
from datetime import date


class UserMeta:
class AdditionalInfo:
status: Optional[str]
birth_date: Optional[date]

def __init__(self, status: Optional[str] = None, birth_date: Optional[date] = None):
self.status = status
self.birth_date = birth_date

email: str
name: str
additional_info: AdditionalInfo

def __init__(self, email: str, name: str, additional_info: AdditionalInfo = AdditionalInfo()):
self.email = email
self.name = name
self.additional_info = additional_info


__id_pwd: Dict[int, str] = {}
__id_user_meta: Dict[int, UserMeta] = {}
__email_id: Dict[str, int] = {}


def get_user_by_auth(email: str, password: str) -> Optional[UserMeta]:
if not is_email_occupied(email):
return None
id = __email_id[email]
if __id_pwd[id] != password:
return None
return __id_user_meta[id]


def is_email_occupied(email: str) -> bool:
return email in __email_id


def add_user(email: str, name: str, password: str):
if is_email_occupied(email):
raise ValueError("User with this email exists")
id = len(__id_pwd)
__id_pwd[id] = password
__id_user_meta[id] = UserMeta(email, name)
__email_id[email] = id


def get_num_of_users():
return len(__id_user_meta)
38 changes: 38 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from fastapi import FastAPI, HTTPException
from pydantic_models.users import UserCreateModel, UserAuthModel
from databases.users import get_user_by_auth, is_email_occupied, add_user, get_num_of_users
from strawberry_types import Query
from strawberry.asgi import GraphQL
import strawberry


datagram_app = FastAPI()


@datagram_app.get("/statistics/")
async def get_statistics(indicator: str):
if indicator != "num_of_users":
raise HTTPException(status_code=404, detail="Don't know this indicator")
get_num_of_users()


@datagram_app.post("/users/registration", status_code=201)
async def register_user(user: UserCreateModel):
if is_email_occupied(user.email):
raise HTTPException(status_code=403, detail="This mail has been already registered")
add_user(user.email, user.name, user.password.get_secret_value())
return "Registered successfully"


@datagram_app.post("/users/auth")
async def authorize(user_auth: UserAuthModel):
user = get_user_by_auth(user_auth.email, user_auth.password.get_secret_value())
if user is None:
raise HTTPException(status_code=403, detail="Wrong mail or password")
return user


schema = strawberry.Schema(query=Query)
graphql_app = GraphQL(schema)
datagram_app.add_route("/graphql", graphql_app)
datagram_app.add_websocket_route("/graphql", graphql_app)
21 changes: 21 additions & 0 deletions pydantic_models/users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from pydantic import BaseModel, EmailStr, SecretStr, validator
from utils import validate_new_password


class UserAuthModel(BaseModel):
email: EmailStr
password: SecretStr

@validator('password')
def password_acceptable(cls, v):
validate_new_password(v)
return v


class UserCreateModel(UserAuthModel):
name: str

@validator('name')
def name_alphanumeric(cls, v):
assert v.isalnum(), 'must be alphanumeric'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert, вообще говоря, не очень хорошо использовать в логике (в тестах - норм). линтеры проверяющие секьюрность на него ругаются, например вот, что думает об assert bandit

return v
46 changes: 46 additions & 0 deletions strawberry_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import List
import strawberry


@strawberry.type
class Date:
year: int
month: int
day: int
Comment on lines +5 to +9

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strawberry поддерживает datetime стандартный, так что можно было его просто использовать



@strawberry.type
class AdditionalInfo:
status: str
birth_date: Date


@strawberry.type
class UserMeta:
email: str
name: str
additional_info: AdditionalInfo


@strawberry.type
class Query:
@strawberry.field
def users(self, year: int) -> List[UserMeta]:
return list(filter(lambda u: u.additional_info.birth_date.year == year, [
UserMeta(
"hello@world.ru",
"Mike",
AdditionalInfo(
"I am a hero",
Date(2001, 1, 1)
)
),
UserMeta(
"hi@world.ru",
"Pol",
AdditionalInfo(
"DevOps",
Date(1001, 1, 1)
)
),
]))
Comment on lines +29 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

как временное решение нормально, но дальновиднее было бы вынести в отдельную функцию, которая бы потом просто переписалась при появлении полноценной БД. Хотя в теории можно и здесь к БД стучаться прямо в лямбде, но лямбды дебажить иногда сложнее и нужно быть осторожным с именем переменной, т.к. скоуп может по невнимательности полететь.

58 changes: 58 additions & 0 deletions tests/integration_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import pytest
from fastapi.testclient import TestClient

from main import datagram_app

client = TestClient(datagram_app)


@pytest.fixture()
def setup_and_teardown_db():
from databases.users import __id_pwd, __id_user_meta
save_pwd = __id_pwd.copy()
save_meta = __id_user_meta.copy()
__id_pwd.clear()
__id_user_meta.clear()

__id_pwd = save_pwd
__id_user_meta = save_meta


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ registration test ~~~~~~~~~~~~~~~~~~
def test_registration_success(setup_and_teardown_db):
rs = client.post("/users/registration", json={"email": "a@yandex.ru", "name": "a", "password": "Aa1!"})
assert rs.status_code == 201


def test_registration_wrong_pwd_format(setup_and_teardown_db):
rs = client.post("/users/registration", json={"email": "a@yandex.ru", "name": "a", "password": "a1!"})
assert rs.status_code == 422


def test_registration_mail_occupied(setup_and_teardown_db):
rs = client.post("/users/registration", json={"email": "a@yandex.ru", "name": "a", "password": "Aa1!"})
assert rs.status_code == 201
rs = client.post("/users/registration", json={"email": "a@yandex.ru", "name": "a", "password": "Aa1!"})
assert rs.status_code == 403


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ auth test ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
def test_auth_success(setup_and_teardown_db):
rs = client.post("/users/registration", json={"email": "a@yandex.ru", "name": "a", "password": "Aa1!"})
assert rs.status_code == 201
rs = client.post("/users/auth", json={"email": "a@yandex.ru", "password": "Aa1!"})
assert rs.status_code == 200


def test_auth_user_not_exist(setup_and_teardown_db):
rs = client.post("/users/registration", json={"email": "a@yandex.ru", "name": "a", "password": "Aa1!"})
assert rs.status_code == 201
rs = client.post("/users/auth", json={"email": "b@yandex.ru", "password": "Aa1!"})
assert rs.status_code == 403


def test_auth_wrong_password(setup_and_teardown_db):
rs = client.post("/users/registration", json={"email": "a@yandex.ru", "name": "a", "password": "Aa1!"})
assert rs.status_code == 201
rs = client.post("/users/auth", json={"email": "a@yandex.ru", "password": "Ba1!"})
assert rs.status_code == 403
84 changes: 84 additions & 0 deletions tests/unit_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import pytest
from utils import validate_new_password
from databases.users import add_user, get_user_by_auth
from pydantic import SecretStr


# ~~~~~~~~~~~~~~~~~ validate password tests ~~~~~~~~~~~~~~~~~~~~
def test_correct_password():
validate_new_password(SecretStr("Ab1!"))


def test_miss_lowercase():
with pytest.raises(ValueError) as ve:
validate_new_password(SecretStr("A1!"))
assert ve.value.args[1] == r"[a-z]"


def test_miss_uppercase():
with pytest.raises(ValueError) as ve:
validate_new_password(SecretStr("b1!"))
assert ve.value.args[1] == r"[A-Z]"


def test_miss_digit():
with pytest.raises(ValueError) as ve:
validate_new_password(SecretStr("Ab!"))
assert ve.value.args[1] == r"[0-9]"


def test_miss_special_char():
with pytest.raises(ValueError) as ve:
validate_new_password(SecretStr("Ab1"))
assert ve.value.args[1] == r"[!@#_.]"


def test_wrong_char():
with pytest.raises(ValueError) as ve:
validate_new_password(SecretStr("Ab1!$"))
assert ve.value.args[1] == "wrong char"


@pytest.fixture()
def setup_and_teardown_db():
from databases.users import __id_pwd, __id_user_meta
save_pwd = __id_pwd.copy()
save_meta = __id_user_meta.copy()
__id_pwd.clear()
__id_user_meta.clear()

__id_pwd = save_pwd
__id_user_meta = save_meta


# ~~~~~~~~~~~~~~~~~ add new user tests ~~~~~~~~~~~~~~~~~~~~
def test_add_user_correct_params(setup_and_teardown_db):
add_user(email="a@yandex.ru", name="a", password="Aa1!")
add_user(email="b@yandex.ru", name="b", password="Bb2@")


def test_add_user_with_existing_name(setup_and_teardown_db):
add_user(email="a@yandex.ru", name="a", password="Aa1!")
add_user(email="b@yandex.ru", name="a", password="Aa1!")


def test_add_user_occupied_email(setup_and_teardown_db):
add_user(email="a@yandex.ru", name="a", password="Aa1!")
with pytest.raises(ValueError):
add_user(email="a@yandex.ru", name="b", password="Bb2@")


# # ~~~~~~~~~~~~~~~~~ get user meta tests ~~~~~~~~~~~~~~~~~~~~
def test_get_user_meta_correct_params(setup_and_teardown_db):
add_user(email="a@yandex.ru", name="a", password="Aa1!")
assert get_user_by_auth(email="a@yandex.ru", password="Aa1!") is not None


def test_get_user_meta_occupied_email(setup_and_teardown_db):
add_user(email="a@yandex.ru", name="a", password="Aa1!")
assert get_user_by_auth(email="aa@yandex.ru", password="Aa1!") is None


def test_get_user_meta_wrong_password(setup_and_teardown_db):
add_user(email="a@yandex.ru", name="a", password="Aa1!")
assert get_user_by_auth(email="a@yandex.ru", password="Aa1!!") is None
17 changes: 17 additions & 0 deletions utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from pydantic import SecretStr
import re


def validate_new_password(pwd: SecretStr):
value = pwd.get_secret_value()
if re.search(r"[a-z]", value) is None:
raise ValueError("password must contain lowercase letter", r"[a-z]")
if re.search(r"[A-Z]", value) is None:
raise ValueError("password must contain uppercase letter", r"[A-Z]")
if re.search(r"[0-9]", value) is None:
raise ValueError("password must contain digit", r"[0-9]")
if re.search(r"[!@#_.]", value) is None:
raise ValueError("password must contain special character '!', '@', '#', '_' or '.'", r"[!@#_.]")
m = re.search(r"[^a-zA-Z0-9!@#_.]", value)
if m is not None:
raise ValueError("password contains forbidden character " + m.group(0), "wrong char")
Comment on lines +5 to +17

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В ситуации, когда регулярное выражение заранее известно, гораздо эффективнее его скомпилировать заранее, т.к. работают они довольно медленно.

Например, можно сделать так:

checkups = [
    (re.compile(r"[a-z]"), "password must contain lowercase letter"),
    ...
]

def validate_new_password(pwd: SecretStr):
    value = pwd.get_secret_value()
    for checkup in checkups:
        if checkup[0].search(value) is None:
            raise ValueError(checkup[1])
    ...

тут нужно подумать, как красивее сделать последнее условие, но глобально скомпилить их заранее лучше, чем делать это каждый раз