- python
- fastapi
- pydantic
- uvicorn
- postgresql
- alembic
- sqlalchemy
- pytest
- создание опроса с 2-5 вариантами ответа
- список опросов
- голосование за один вариант
- результаты с количеством голосов по каждому варианту
- ручное закрытие опроса
- автоматическое закрытие по таймеру
- единый error contract
- простая обязательная авторизация для голосования через заголовок
X-User-Id - uuid для основных сущностей
- отдельный
option_idвнутри опроса - все даты и время в ответах по москве
app/
api/ # роуты fastapi
core/ # настройки, время, авторизация и обработка ошибок
db/ # подключение к бд и базовый класс моделей
models/ # sqlalchemy-модели
schemas/ # pydantic-схемы
services/ # бизнес-логика
alembic/ # миграции
tests/ # pytest-тесты
post /api/v1/polls
пример тела запроса:
{
"question": "какой язык выбрать для первого pet-проекта?",
"options": ["python", "go", "java"],
"close_after_seconds": 60
}пояснения:
question— непустой вопросoptions— от 2 до 5 непустых вариантовclose_after_seconds— необязательный таймер автозакрытия в секундах
get /api/v1/polls
post /api/v1/polls/{poll_id}/votes
для голосования нужен обязательный заголовок X-User-Id
пример тела запроса:
{
"option_id": 1
}пример заголовка:
X-User-Id: user-42
poll_id — uuid опроса
option_id — номер варианта внутри конкретного опроса
get /api/v1/polls/{poll_id}/results
post /api/v1/polls/{poll_id}/close
повторное закрытие возвращает 409 conflict с кодом ошибки poll_already_closed
все ошибки возвращаются в одном формате:
{
"error": {
"code": "duplicate_vote",
"message": "один пользователь не может голосовать дважды в одном опросе",
"details": {
"poll_id": "426f2d84-5b97-45bd-8b8e-b16e279e32bf",
"user_id": "user-42"
}
}
}логика http-статусов:
401— не передан обязательныйX-User-Idили он пустой404— опрос или вариант ответа не найден409— состояние конфликтует с правилом бизнеса422— входные данные не прошли валидацию
примеры кодов ошибок:
validation_error— ошибка входной валидацииauthentication_required— не переданX-User-Idpoll_not_found— опрос не найденoption_not_found— вариант не найден в рамках опросаduplicate_vote— повторное голосованиеpoll_closed— попытка проголосовать в закрытый опросpoll_already_closed— повторное закрытие
make install
make db-up
make migrate
make runпроверка сервиса:
curl http://localhost:8000/docsостановка postgresql:
make db-downmake docker-build
make docker-upпри старте контейнера приложение само дожидается postgresql, накатывает alembic-миграции и только после этого запускает api
проверка сервиса:
curl http://localhost:8000/api/v1/pollsостановка контейнеров:
make docker-downзапуск тестов:
make db-up
make testтесты используют реальный postgresql и поднимают схему только через alembic-миграции
если postgresql доступен не на localhost:5432, можно передать TEST_DATABASE_ADMIN_URL:
export TEST_DATABASE_ADMIN_URL=postgresql+psycopg://postgres:postgres@localhost:5432/postgres
make testили напрямую:
PYTHONPATH=. pytest -qоткат последней миграции:
make migrate-downлоги postgresql:
make db-logsлоги контейнеров:
make docker-logscurl -X POST http://localhost:8000/api/v1/polls \
-H 'Content-Type: application/json' \
-d '{
"question": "какой фреймворк выбрать?",
"options": ["fastapi", "django", "flask"],
"close_after_seconds": 120
}'curl http://localhost:8000/api/v1/pollscurl -X POST http://localhost:8000/api/v1/polls/<poll_uuid>/votes \
-H 'Content-Type: application/json' \
-H 'X-User-Id: user-1' \
-d '{
"option_id": 1
}'curl http://localhost:8000/api/v1/polls/<poll_uuid>/resultscurl -X POST http://localhost:8000/api/v1/polls/<poll_uuid>/close- создать опрос с валидным вопросом и 2-5 вариантами и убедиться, что
idвыглядит как uuid, а варианты имеютoption_id1, 2, 3 и так далее - попробовать создать опрос с пустым вопросом и убедиться, что приходит
422иvalidation_error - попробовать создать опрос с одним вариантом ответа и убедиться, что приходит
422 - попробовать проголосовать без
X-User-Idи убедиться, что приходит401иauthentication_required - проголосовать за существующий вариант и проверить, что в результатах увеличился
total_votesи счетчик нужного варианта - проголосовать повторно тем же
X-User-Idв том же опросе и убедиться, что приходит409иduplicate_vote - закрыть опрос и попробовать проголосовать после закрытия и убедиться, что приходит
409иpoll_closed
повторное голосование определяется по паре (poll_id, user_id)
почему так:
poll_idкак uuid делает внешний идентификатор опроса стабильным и не завязанным на простой инкрементuser_idберется из обязательного заголовкаX-User-Id- уникальное ограничение в базе дополнительно защищает от двойной записи при параллельных запросах
в таблице poll_options есть внутренний uuid id и отдельный числовой option_id
это сделано затем, чтобы:
- внешний контракт оставался простым
- вариант ответа был понятен клиенту как
1,2,3 option_idсчитался только внутри конкретного опроса, а не глобально по всей таблице