mirror of
https://github.com/wowlikon/LibraryAPI.git
synced 2025-12-11 21:30:46 +00:00
Compare commits
8 Commits
4d0d276efe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c24f66de0 | |||
| a757e69ad5 | |||
|
|
20dbf34fa6 | ||
| a7dc4890c6 | |||
| 99de648fa9 | |||
|
|
e57812ffc9 | ||
| a3ccd8a466 | |||
| ddcae6cdf6 |
4
.env
4
.env
@@ -1,4 +1,4 @@
|
||||
POSTGRES_USER = "postgres"
|
||||
POSTGRES_PASSWORD = "password"
|
||||
POSTGRES_DB = "mydatabase"
|
||||
POSTGRES_PASSWORD = "postgres"
|
||||
POSTGRES_DB = "postgres"
|
||||
POSTGRES_SERVER = "db"
|
||||
|
||||
56
.gitignore
vendored
56
.gitignore
vendored
@@ -3,9 +3,6 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
@@ -27,12 +24,6 @@ share/python-wheels/
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
@@ -51,36 +42,6 @@ coverage.xml
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
@@ -94,13 +55,6 @@ ipython_config.py
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
# .env
|
||||
.venv
|
||||
@@ -110,16 +64,9 @@ ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
@@ -134,8 +81,5 @@ dmypy.json
|
||||
# VS code
|
||||
.vscode
|
||||
|
||||
# JUPITER
|
||||
*.ipynb
|
||||
|
||||
# Postgres data
|
||||
data/
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Nemchinov Ilya
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
154
README.md
154
README.md
@@ -1,98 +1,144 @@
|
||||

|
||||
# LibraryAPI
|
||||
|
||||
This project is a test web application built using FastAPI, a modern web framework for creating APIs in Python. It showcases the use of Pydantic for data validation, SQLModel for database interactions, Alembic for migration management, PostgreSQL as the database system, and Docker Compose for easy deployment.
|
||||
Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания.
|
||||
|
||||
### **Key Components:**
|
||||
### **Ключевые элементы:**
|
||||
|
||||
1. FastAPI: Provides high performance and simplicity for developing RESTful APIs, supporting asynchronous operations and automatic documentation generation.
|
||||
2. Pydantic: Used for data validation and serialization, allowing easy definition of data schemas.
|
||||
3. SQLModel: Combines SQLAlchemy and Pydantic, enabling database operations with Python classes.
|
||||
4. Alembic: A tool for managing database migrations, making it easy to track and apply changes to the database schema.
|
||||
5. PostgreSQL: A reliable relational database used for data storage.
|
||||
6. Docker Compose: Simplifies the deployment of the application and its dependencies in containers.
|
||||
1. FastAPI: Предоставляет высокопроизводительность и простоту для разработки RESTful API, поддерживает асинхронные операции и автоматическую генерацию документации.
|
||||
2. Pydantic: Используется для валидации данных и сериализации, позволяет легко определить схемы данных.
|
||||
3. SQLModel: Объединяет SQLAlchemy и Pydantic, включая операции с базой данных с помощью классов Python.
|
||||
4. Alembic: Инструмент для управления миграциями базы данных, упрощающий отслеживание и применение изменений в схеме базы данных.
|
||||
5. PostgreSQL: Надежная реляционная база данных для хранения данных.
|
||||
6. Docker Compose: Упрощает развертывание приложения и его зависимостей в контейнерах.
|
||||
|
||||
|
||||
### **Installation Instructions**
|
||||
### **Инструкция по установке**
|
||||
|
||||
For development:
|
||||
|
||||
1. Clone the repository:
|
||||
1. Клонируйте репозиторий:
|
||||
```bash
|
||||
git clone https://github.com/wowlikon/libraryapi.git
|
||||
```
|
||||
|
||||
2. Navigate to the project directory:
|
||||
2. Перейдите в каталог проекта:
|
||||
```bash
|
||||
cd libraryapi
|
||||
```
|
||||
|
||||
3. Configure environment variables:
|
||||
3. Настройте переменные окружения:
|
||||
```bash
|
||||
edit .env
|
||||
```
|
||||
|
||||
4. Build the Docker containers:
|
||||
4. Соберите контейнеры Docker:
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
5. Run the application:
|
||||
5. Запустите приложение:
|
||||
```bash
|
||||
docker compose up api
|
||||
```
|
||||
|
||||
For make new migrations:
|
||||
Для создания новых миграций:
|
||||
```bash
|
||||
docker compose run --rm -T api alembic revision --autogenerate -m "Migration name"
|
||||
```
|
||||
|
||||
For run tests:
|
||||
Для запуска тестов:
|
||||
```bash
|
||||
docker compose up test
|
||||
```
|
||||
|
||||
### **API Endpoints**
|
||||
### **Эндпоинты API**
|
||||
|
||||
**Authors**
|
||||
| Method | Endpoint | Description |
|
||||
|--------|-----------------------|------------------------------------------------|
|
||||
| POST | `/authors` | Create a new author |
|
||||
| GET | `/authors` | Retrieve a list of all authors |
|
||||
| GET | `/authors/{id}` | Retrieve a specific author by ID with books |
|
||||
| PUT | `/authors/{id}` | Update a specific author by ID |
|
||||
| DELETE | `/authors/{id}` | Delete a specific author by ID |
|
||||
| GET | `/authors/{id}/books` | Retrieve a list of books for a specific author |
|
||||
**Авторы**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/authors` | Создать нового автора |
|
||||
| GET | `/authors` | Получить список всех авторов |
|
||||
| GET | `/authors/{id}` | Получить автора по ID с книгами |
|
||||
| PUT | `/authors/{id}` | Обновить автора по ID |
|
||||
| DELETE | `/authors/{id}` | Удалить автора по ID |
|
||||
|
||||
**Books**
|
||||
| Method | Endpoint | Description |
|
||||
|--------|-----------------------|------------------------------------------------|
|
||||
| POST | `/books` | Create a new book |
|
||||
| GET | `/books` | Retrieve a list of all books |
|
||||
| GET | `/book/{id}` | Retrieve a specific book by ID with authors |
|
||||
| PUT | `/books/{id}` | Update a specific book by ID |
|
||||
| DELETE | `/books/{id}` | Delete a specific book by ID |
|
||||
| GET | `/books/{id}/authors` | Retrieve a list of authors for a specific book |
|
||||
**Книги**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/books` | Создать новую книгу |
|
||||
| GET | `/books` | Получить список всех книг |
|
||||
| GET | `/book/{id}` | Получить книгу по ID с авторами |
|
||||
| PUT | `/books/{id}` | Обновить книгу по ID |
|
||||
| DELETE | `/books/{id}` | Удалить книгу по ID |
|
||||
|
||||
**Relationships**
|
||||
| Method | Endpoint | Description |
|
||||
|--------|------------------------------|-----------------------------------------|
|
||||
| GET | `/relationships/author-book` | Retrieve a list of all relationships |
|
||||
| POST | `/relationships/author-book` | Add author-book relationship |
|
||||
| DELETE | `/relationships/author-book` | Remove author-book relationship |
|
||||
**Жанры**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/genres` | Создать новый жанр |
|
||||
| GET | `/genres` | Получить список всех жанров |
|
||||
| GET | `/genres/{id}` | Получить жанр по ID |
|
||||
| PUT | `/genres/{id}` | Обновить жанр по ID |
|
||||
| DELETE | `/genres/{id}` | Удалить жанр по ID |
|
||||
|
||||
**Связи**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|------------------------------|-----------------------------------|
|
||||
| GET | `/authors/{id}/books` | Получить список книг для автора |
|
||||
| GET | `/books/{id}/authors` | Получить список авторов для книги |
|
||||
| POST | `/relationships/author-book` | Связать автор-книга |
|
||||
| DELETE | `/relationships/author-book` | Разделить автор-книга |
|
||||
| GET | `/genres/{id}/books` | Получить список книг для жанра |
|
||||
| GET | `/books/{id}/genres` | Получить список жанров для книги |
|
||||
| POST | `/relationships/genre-book` | Связать автор-книга |
|
||||
| DELETE | `/relationships/genre-book` | Разделить автор-книга |
|
||||
|
||||
**Другие**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-------------|-------------------------------|
|
||||
| GET | `/api/info` | Получить информацию о сервисе |
|
||||
|
||||
|
||||
### **Technologies Used**
|
||||
```mermaid
|
||||
erDiagram
|
||||
AUTHOR {
|
||||
int id PK "ID автора"
|
||||
string name "Имя автора"
|
||||
}
|
||||
|
||||
BOOK {
|
||||
int id PK "ID книги"
|
||||
string title "Название книги"
|
||||
string description "Описание книги"
|
||||
}
|
||||
|
||||
- **FastAPI**: A modern web framework for building APIs with Python, known for its speed and ease of use.
|
||||
- **Pydantic**: A data validation and settings management library that uses Python type annotations.
|
||||
- **SQLModel**: A library for interacting with databases using Python classes, combining the features of SQLAlchemy and Pydantic.
|
||||
- **Alembic**: A lightweight database migration tool for use with SQLAlchemy.
|
||||
- **PostgreSQL**: A powerful, open-source relational database management system.
|
||||
- **Docker**: A platform for developing, shipping, and running applications in containers.
|
||||
- **Docker Compose**: A tool for defining and running multi-container Docker applications.
|
||||
GENRE {
|
||||
int id PK "ID жанра"
|
||||
string name "Название жанра"
|
||||
}
|
||||
|
||||
AUTHOR_BOOK {
|
||||
int author_id FK "ID автора"
|
||||
int book_id FK "ID книги"
|
||||
}
|
||||
|
||||
GENRE_BOOK {
|
||||
int genre_id FK "ID жанра"
|
||||
int book_id FK "ID книги"
|
||||
}
|
||||
|
||||
AUTHOR ||--o{ AUTHOR_BOOK : "писал"
|
||||
BOOK ||--o{ AUTHOR_BOOK : "написан"
|
||||
|
||||
BOOK ||--o{ GENRE_BOOK : "принадлежит"
|
||||
GENRE ||--o{ GENRE_BOOK : "содержит"
|
||||
```
|
||||
|
||||
|
||||
### **TODO List**
|
||||
### **Используемые технологии**
|
||||
|
||||
- Geners table and endpoints
|
||||
- Update tests
|
||||
- **FastAPI**: Современный web фреймворк для построения API с использованием Python, известный своей скоростью и простотой использования.
|
||||
- **Pydantic**: Библиотека для валидации данных и управления настройками, использующая аннотации типов Python.
|
||||
- **SQLModel**: Библиотека для взаимодействия с базами данных с использованием классов Python, объединяющая функции SQLAlchemy и Pydantic.
|
||||
- **Alembic**: Легковесный инструмент для миграции базы данных на основе SQLAlchemy.
|
||||
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных.
|
||||
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах.
|
||||
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker.
|
||||
|
||||
0
alembic.ini
Executable file → Normal file
0
alembic.ini
Executable file → Normal file
@@ -1,18 +1,18 @@
|
||||
services:
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres
|
||||
expose:
|
||||
- 5432
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
image: postgres:17
|
||||
ports:
|
||||
- 5432:5432
|
||||
# volumes:
|
||||
# - ./data/db:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./.env
|
||||
|
||||
api:
|
||||
container_name: api
|
||||
build: .
|
||||
command: bash -c "alembic upgrade head && uvicorn library_service.main:app --reload --host 0.0.0.0 --port 8000"
|
||||
command: bash -c "alembic upgrade head && uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
volumes:
|
||||
- .:/code
|
||||
ports:
|
||||
@@ -26,5 +26,3 @@ services:
|
||||
command: bash -c "pytest tests"
|
||||
volumes:
|
||||
- .:/code
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
import asyncpg
|
||||
|
||||
router = APIRouter(
|
||||
prefix='/devices'
|
||||
)
|
||||
1
library_service/favicon.svg
Normal file
1
library_service/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><rect x="10" y="10" width="80" height="80" rx="4" ry="4" fill="#fff" stroke="#000" stroke-width="2"/><rect x="20" y="15" width="60" height="70" rx="10" ry="10"/><rect x="20" y="15" width="60" height="66" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="62" rx="10" ry="10"/><rect x="20" y="15" width="60" height="60" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="56" rx="10" ry="10"/><rect x="20" y="15" width="60" height="54" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="50" rx="10" ry="10"/><rect x="20" y="15" width="60" height="48" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="44" rx="10" ry="10"/><rect x="22" y="21" width="2" height="58" rx="10" ry="10" stroke="#000" stroke-width="4"/><rect x="22" y="55" width="4" height="26" rx="2" ry="15"/><text x="50" y="40" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" stroke="#fff" stroke-width=".5" fill="none" font-size="20">『LiB』</text></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -11,18 +11,20 @@ from .routers.misc import get_info
|
||||
app = get_app()
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
print("[+] Initializing...")
|
||||
|
||||
# Initialize the database
|
||||
# Настройка базы данных
|
||||
with engine.begin() as connection:
|
||||
alembic_cfg.attributes['connection'] = connection
|
||||
alembic_cfg.attributes["connection"] = connection
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
print("[+] Starting...")
|
||||
yield # Here FastAPI will start handling requests;
|
||||
yield # Обработка запросов
|
||||
print("[+] Application shutdown")
|
||||
|
||||
# Include routers
|
||||
|
||||
# Подключение маршрутов
|
||||
app.include_router(api_router)
|
||||
|
||||
@@ -2,16 +2,24 @@ from .author import Author
|
||||
from .book import Book
|
||||
from .genre import Genre
|
||||
from .links import (
|
||||
AuthorBookLink, GenreBookLink,
|
||||
AuthorWithBooks, BookWithAuthors,
|
||||
GenreWithBooks, BookWithGenres,
|
||||
BookWithAuthorsAndGenres
|
||||
AuthorBookLink,
|
||||
GenreBookLink,
|
||||
AuthorWithBooks,
|
||||
BookWithAuthors,
|
||||
GenreWithBooks,
|
||||
BookWithGenres,
|
||||
BookWithAuthorsAndGenres,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Author', 'Book', 'Genre',
|
||||
'AuthorBookLink', 'AuthorWithBooks',
|
||||
'BookWithAuthors', 'GenreBookLink',
|
||||
'GenreWithBooks', 'BookWithGenres',
|
||||
'BookWithAuthorsAndGenres'
|
||||
"Author",
|
||||
"Book",
|
||||
"Genre",
|
||||
"AuthorBookLink",
|
||||
"AuthorWithBooks",
|
||||
"BookWithAuthors",
|
||||
"GenreBookLink",
|
||||
"GenreWithBooks",
|
||||
"BookWithGenres",
|
||||
"BookWithAuthorsAndGenres",
|
||||
]
|
||||
|
||||
@@ -6,9 +6,9 @@ from .links import AuthorBookLink
|
||||
if TYPE_CHECKING:
|
||||
from .book import Book
|
||||
|
||||
|
||||
class Author(AuthorBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True, index=True)
|
||||
books: List["Book"] = Relationship(
|
||||
back_populates="authors",
|
||||
link_model=AuthorBookLink
|
||||
back_populates="authors", link_model=AuthorBookLink
|
||||
)
|
||||
|
||||
@@ -7,13 +7,12 @@ if TYPE_CHECKING:
|
||||
from .author import Author
|
||||
from .genre import Genre
|
||||
|
||||
|
||||
class Book(BookBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True, index=True)
|
||||
authors: List["Author"] = Relationship(
|
||||
back_populates="books",
|
||||
link_model=AuthorBookLink
|
||||
back_populates="books", link_model=AuthorBookLink
|
||||
)
|
||||
genres: List["Genre"] = Relationship(
|
||||
back_populates="books",
|
||||
link_model=GenreBookLink
|
||||
back_populates="books", link_model=GenreBookLink
|
||||
)
|
||||
|
||||
@@ -6,9 +6,9 @@ from .links import GenreBookLink
|
||||
if TYPE_CHECKING:
|
||||
from .book import Book
|
||||
|
||||
|
||||
class Genre(GenreBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True, index=True)
|
||||
books: List["Book"] = Relationship(
|
||||
back_populates="genres",
|
||||
link_model=GenreBookLink
|
||||
back_populates="genres", link_model=GenreBookLink
|
||||
)
|
||||
|
||||
@@ -5,26 +5,35 @@ from library_service.models.dto.author import AuthorRead
|
||||
from library_service.models.dto.book import BookRead
|
||||
from library_service.models.dto.genre import GenreRead
|
||||
|
||||
|
||||
class AuthorBookLink(SQLModel, table=True):
|
||||
author_id: int | None = Field(default=None, foreign_key="author.id", primary_key=True)
|
||||
author_id: int | None = Field(
|
||||
default=None, foreign_key="author.id", primary_key=True
|
||||
)
|
||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
||||
|
||||
|
||||
class GenreBookLink(SQLModel, table=True):
|
||||
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
|
||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
||||
|
||||
|
||||
class AuthorWithBooks(AuthorRead):
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithAuthors(BookRead):
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithGenres(BookRead):
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenreWithBooks(GenreRead):
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithAuthorsAndGenres(BookRead):
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
from .author import (
|
||||
AuthorBase, AuthorCreate, AuthorUpdate,
|
||||
AuthorRead, AuthorList
|
||||
)
|
||||
from .book import (
|
||||
BookBase, BookCreate, BookUpdate,
|
||||
BookRead, BookList
|
||||
)
|
||||
from .author import AuthorBase, AuthorCreate, AuthorUpdate, AuthorRead, AuthorList
|
||||
from .book import BookBase, BookCreate, BookUpdate, BookRead, BookList
|
||||
|
||||
from .genre import (
|
||||
GenreBase, GenreCreate, GenreUpdate,
|
||||
GenreRead, GenreList
|
||||
)
|
||||
from .genre import GenreBase, GenreCreate, GenreUpdate, GenreRead, GenreList
|
||||
|
||||
__all__ = [
|
||||
'AuthorBase', 'AuthorCreate', 'AuthorUpdate', 'AuthorRead', 'AuthorList',
|
||||
'BookBase', 'BookCreate', 'BookUpdate', 'BookRead', 'BookList',
|
||||
'GenreBase', 'GenreCreate', 'GenreUpdate', 'GenreRead', 'GenreList',
|
||||
"AuthorBase",
|
||||
"AuthorCreate",
|
||||
"AuthorUpdate",
|
||||
"AuthorRead",
|
||||
"AuthorList",
|
||||
"BookBase",
|
||||
"BookCreate",
|
||||
"BookUpdate",
|
||||
"BookRead",
|
||||
"BookList",
|
||||
"GenreBase",
|
||||
"GenreCreate",
|
||||
"GenreUpdate",
|
||||
"GenreRead",
|
||||
"GenreList",
|
||||
]
|
||||
|
||||
@@ -2,24 +2,27 @@ from sqlmodel import SQLModel
|
||||
from pydantic import ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class AuthorBase(SQLModel):
|
||||
name: str
|
||||
|
||||
model_config = ConfigDict( #pyright: ignore
|
||||
json_schema_extra={
|
||||
"example": {"name": "author_name"}
|
||||
}
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={"example": {"name": "author_name"}}
|
||||
)
|
||||
|
||||
|
||||
class AuthorCreate(AuthorBase):
|
||||
pass
|
||||
|
||||
|
||||
class AuthorUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class AuthorRead(AuthorBase):
|
||||
id: int
|
||||
|
||||
|
||||
class AuthorList(SQLModel):
|
||||
authors: List[AuthorRead]
|
||||
total: int
|
||||
|
||||
@@ -2,29 +2,31 @@ from sqlmodel import SQLModel
|
||||
from pydantic import ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class BookBase(SQLModel):
|
||||
title: str
|
||||
description: str
|
||||
|
||||
model_config = ConfigDict( #pyright: ignore
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"title": "book_title",
|
||||
"description": "book_description"
|
||||
}
|
||||
"example": {"title": "book_title", "description": "book_description"}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BookCreate(BookBase):
|
||||
pass
|
||||
|
||||
|
||||
class BookUpdate(SQLModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class BookRead(BookBase):
|
||||
id: int
|
||||
|
||||
|
||||
class BookList(SQLModel):
|
||||
books: List[BookRead]
|
||||
total: int
|
||||
|
||||
@@ -2,24 +2,27 @@ from sqlmodel import SQLModel
|
||||
from pydantic import ConfigDict
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class GenreBase(SQLModel):
|
||||
name: str
|
||||
|
||||
model_config = ConfigDict( #pyright: ignore
|
||||
json_schema_extra={
|
||||
"example": {"name": "genre_name"}
|
||||
}
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={"example": {"name": "genre_name"}}
|
||||
)
|
||||
|
||||
|
||||
class GenreCreate(GenreBase):
|
||||
pass
|
||||
|
||||
|
||||
class GenreUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class GenreRead(GenreBase):
|
||||
id: int
|
||||
|
||||
|
||||
class GenreList(SQLModel):
|
||||
genres: List[GenreRead]
|
||||
total: int
|
||||
|
||||
@@ -2,13 +2,15 @@ from fastapi import APIRouter
|
||||
|
||||
from .authors import router as authors_router
|
||||
from .books import router as books_router
|
||||
from .genres import router as genres_router
|
||||
from .relationships import router as relationships_router
|
||||
from .misc import router as misc_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Including all routers
|
||||
# Подключение всех маршрутов
|
||||
api_router.include_router(authors_router)
|
||||
api_router.include_router(books_router)
|
||||
api_router.include_router(genres_router)
|
||||
api_router.include_router(relationships_router)
|
||||
api_router.include_router(misc_router)
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Path, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks
|
||||
from library_service.models.dto import (
|
||||
AuthorCreate, AuthorUpdate, AuthorRead,
|
||||
AuthorList, BookRead
|
||||
AuthorCreate,
|
||||
AuthorUpdate,
|
||||
AuthorRead,
|
||||
AuthorList,
|
||||
BookRead,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
|
||||
|
||||
# Create an author
|
||||
@router.post("/", response_model=AuthorRead)
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=AuthorRead,
|
||||
summary="Создать автора",
|
||||
description="Добавляет автора в систему",
|
||||
)
|
||||
def create_author(author: AuthorCreate, session: Session = Depends(get_session)):
|
||||
db_author = Author(**author.model_dump())
|
||||
session.add(db_author)
|
||||
@@ -19,41 +29,60 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
|
||||
session.refresh(db_author)
|
||||
return AuthorRead(**db_author.model_dump())
|
||||
|
||||
|
||||
# Read authors
|
||||
@router.get("/", response_model=AuthorList)
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=AuthorList,
|
||||
summary="Получить список авторов",
|
||||
description="Возвращает список всех авторов в системе",
|
||||
)
|
||||
def read_authors(session: Session = Depends(get_session)):
|
||||
authors = session.exec(select(Author)).all()
|
||||
return AuthorList(
|
||||
authors=[AuthorRead(**author.model_dump()) for author in authors],
|
||||
total=len(authors)
|
||||
total=len(authors),
|
||||
)
|
||||
|
||||
|
||||
# Read an author with their books
|
||||
@router.get("/{author_id}", response_model=AuthorWithBooks)
|
||||
def get_author(author_id: int, session: Session = Depends(get_session)):
|
||||
@router.get(
|
||||
"/{author_id}",
|
||||
response_model=AuthorWithBooks,
|
||||
summary="Получить информацию об авторе",
|
||||
description="Возвращает информацию об авторе и его книгах",
|
||||
)
|
||||
def get_author(
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
|
||||
books = session.exec(
|
||||
select(Book)
|
||||
.join(AuthorBookLink)
|
||||
.where(AuthorBookLink.author_id == author_id)
|
||||
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
|
||||
).all()
|
||||
|
||||
book_reads = [BookRead(**book.model_dump()) for book in books]
|
||||
|
||||
author_data = author.model_dump()
|
||||
author_data['books'] = book_reads
|
||||
author_data["books"] = book_reads
|
||||
|
||||
return AuthorWithBooks(**author_data)
|
||||
|
||||
|
||||
# Update an author
|
||||
@router.put("/{author_id}", response_model=AuthorRead)
|
||||
@router.put(
|
||||
"/{author_id}",
|
||||
response_model=AuthorRead,
|
||||
summary="Обновить информацию об авторе",
|
||||
description="Обновляет информацию об авторе в системе",
|
||||
)
|
||||
def update_author(
|
||||
author_id: int,
|
||||
author: AuthorUpdate,
|
||||
session: Session = Depends(get_session)
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
db_author = session.get(Author, author_id)
|
||||
if not db_author:
|
||||
@@ -67,9 +96,18 @@ def update_author(
|
||||
session.refresh(db_author)
|
||||
return AuthorRead(**db_author.model_dump())
|
||||
|
||||
|
||||
# Delete an author
|
||||
@router.delete("/{author_id}", response_model=AuthorRead)
|
||||
def delete_author(author_id: int, session: Session = Depends(get_session)):
|
||||
@router.delete(
|
||||
"/{author_id}",
|
||||
response_model=AuthorRead,
|
||||
summary="Удалить автора",
|
||||
description="Удаляет автора из системы",
|
||||
)
|
||||
def delete_author(
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Path, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.models.db.links import BookWithAuthorsAndGenres
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink
|
||||
from library_service.models.dto import (
|
||||
AuthorRead, BookList, BookRead,
|
||||
BookCreate, BookUpdate
|
||||
AuthorRead,
|
||||
BookList,
|
||||
BookRead,
|
||||
BookCreate,
|
||||
BookUpdate,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/books", tags=["books"])
|
||||
|
||||
|
||||
# Create a book
|
||||
@router.post("/", response_model=Book)
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=Book,
|
||||
summary="Создать книгу",
|
||||
description="Добавляет книгу в систему",
|
||||
)
|
||||
def create_book(book: BookCreate, session: Session = Depends(get_session)):
|
||||
db_book = Book(**book.model_dump())
|
||||
session.add(db_book)
|
||||
@@ -19,38 +30,67 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
|
||||
session.refresh(db_book)
|
||||
return BookRead(**db_book.model_dump())
|
||||
|
||||
|
||||
# Read books
|
||||
@router.get("/", response_model=BookList)
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=BookList,
|
||||
summary="Получить список книг",
|
||||
description="Возвращает список всех книг в системе",
|
||||
)
|
||||
def read_books(session: Session = Depends(get_session)):
|
||||
books = session.exec(select(Book)).all()
|
||||
return BookList(
|
||||
books=[BookRead(**book.model_dump()) for book in books],
|
||||
total=len(books)
|
||||
books=[BookRead(**book.model_dump()) for book in books], total=len(books)
|
||||
)
|
||||
|
||||
# Read a book with their authors
|
||||
@router.get("/{book_id}", response_model=BookWithAuthors)
|
||||
def get_book(book_id: int, session: Session = Depends(get_session)):
|
||||
|
||||
# Read a book with their authors and genres
|
||||
@router.get(
|
||||
"/{book_id}",
|
||||
response_model=BookWithAuthorsAndGenres,
|
||||
summary="Получить информацию о книге",
|
||||
description="Возвращает информацию о книге, её авторах и жанрах",
|
||||
)
|
||||
def get_book(
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
authors = session.exec(
|
||||
select(Author)
|
||||
.join(AuthorBookLink)
|
||||
.where(AuthorBookLink.book_id == book_id)
|
||||
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
|
||||
).all()
|
||||
|
||||
author_reads = [AuthorRead(**author.model_dump()) for author in authors]
|
||||
|
||||
genres = session.exec(
|
||||
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
|
||||
).all()
|
||||
|
||||
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
|
||||
|
||||
book_data = book.model_dump()
|
||||
book_data['authors'] = author_reads
|
||||
book_data["authors"] = author_reads
|
||||
book_data["genres"] = genre_reads
|
||||
|
||||
return BookWithAuthors(**book_data)
|
||||
|
||||
|
||||
# Update a book
|
||||
@router.put("/{book_id}", response_model=Book)
|
||||
def update_book(book_id: int, book: BookUpdate, session: Session = Depends(get_session)):
|
||||
@router.put(
|
||||
"/{book_id}",
|
||||
response_model=Book,
|
||||
summary="Обновить информацию о книге",
|
||||
description="Обновляет информацию о книге в системе",
|
||||
)
|
||||
def update_book(
|
||||
book: BookUpdate,
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
db_book = session.get(Book, book_id)
|
||||
if not db_book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
@@ -61,13 +101,24 @@ def update_book(book_id: int, book: BookUpdate, session: Session = Depends(get_s
|
||||
session.refresh(db_book)
|
||||
return db_book
|
||||
|
||||
|
||||
# Delete a book
|
||||
@router.delete("/{book_id}", response_model=BookRead)
|
||||
def delete_book(book_id: int, session: Session = Depends(get_session)):
|
||||
@router.delete(
|
||||
"/{book_id}",
|
||||
response_model=BookRead,
|
||||
summary="Удалить книгу",
|
||||
description="Удаляет книгу их системы",
|
||||
)
|
||||
def delete_book(
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
book_read = BookRead(id=(book.id or 0), title=book.title, description=book.description)
|
||||
book_read = BookRead(
|
||||
id=(book.id or 0), title=book.title, description=book.description
|
||||
)
|
||||
session.delete(book)
|
||||
session.commit()
|
||||
return book_read
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Path, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Genre, GenreBookLink, Book, GenreWithBooks
|
||||
from library_service.models.dto import (
|
||||
GenreCreate, GenreUpdate, GenreRead,
|
||||
GenreList, BookRead
|
||||
GenreCreate,
|
||||
GenreUpdate,
|
||||
GenreRead,
|
||||
GenreList,
|
||||
BookRead,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
|
||||
|
||||
# Create a genre
|
||||
@router.post("/", response_model=GenreRead)
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=GenreRead,
|
||||
summary="Создать жанр",
|
||||
description="Добавляет жанр книг в систему",
|
||||
)
|
||||
def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
|
||||
db_genre = Genre(**genre.model_dump())
|
||||
session.add(db_genre)
|
||||
@@ -19,41 +29,59 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
|
||||
session.refresh(db_genre)
|
||||
return GenreRead(**db_genre.model_dump())
|
||||
|
||||
|
||||
# Read genres
|
||||
@router.get("/", response_model=GenreList)
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=GenreList,
|
||||
summary="Получить список жанров",
|
||||
description="Возвращает список всех жанров в системе",
|
||||
)
|
||||
def read_genres(session: Session = Depends(get_session)):
|
||||
genres = session.exec(select(Genre)).all()
|
||||
return GenreList(
|
||||
genres=[GenreRead(**genre.model_dump()) for genre in genres],
|
||||
total=len(genres)
|
||||
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
|
||||
)
|
||||
|
||||
|
||||
# Read a genre with their books
|
||||
@router.get("/{genre_id}", response_model=GenreWithBooks)
|
||||
def get_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
@router.get(
|
||||
"/{genre_id}",
|
||||
response_model=GenreWithBooks,
|
||||
summary="Получить информацию о жанре",
|
||||
description="Возвращает информацию о жанре и книгах с ним",
|
||||
)
|
||||
def get_genre(
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
|
||||
books = session.exec(
|
||||
select(Book)
|
||||
.join(GenreBookLink)
|
||||
.where(GenreBookLink.genre_id == genre_id)
|
||||
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
|
||||
).all()
|
||||
|
||||
book_reads = [BookRead(**book.model_dump()) for book in books]
|
||||
|
||||
genre_data = genre.model_dump()
|
||||
genre_data['books'] = book_reads
|
||||
genre_data["books"] = book_reads
|
||||
|
||||
return GenreWithBooks(**genre_data)
|
||||
|
||||
|
||||
# Update a genre
|
||||
@router.put("/{genre_id}", response_model=GenreRead)
|
||||
@router.put(
|
||||
"/{genre_id}",
|
||||
response_model=GenreRead,
|
||||
summary="Обновляет информацию о жанре",
|
||||
description="Обновляет информацию о жанре в системе",
|
||||
)
|
||||
def update_genre(
|
||||
genre_id: int,
|
||||
genre: GenreUpdate,
|
||||
session: Session = Depends(get_session)
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
db_genre = session.get(Genre, genre_id)
|
||||
if not db_genre:
|
||||
@@ -67,9 +95,18 @@ def update_genre(
|
||||
session.refresh(db_genre)
|
||||
return GenreRead(**db_genre.model_dump())
|
||||
|
||||
|
||||
# Delete a genre
|
||||
@router.delete("/{genre_id}", response_model=GenreRead)
|
||||
def delete_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
@router.delete(
|
||||
"/{genre_id}",
|
||||
response_model=GenreRead,
|
||||
summary="Удалить жанр",
|
||||
description="Удаляет автора из системы",
|
||||
)
|
||||
def delete_genre(
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
from fastapi import APIRouter, Request, FastAPI
|
||||
from fastapi import APIRouter, Path, Request
|
||||
from fastapi.params import Depends
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
from httpx import get
|
||||
|
||||
from library_service.settings import get_app
|
||||
|
||||
# Templates initialization
|
||||
# Загрузка шаблонов
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||
|
||||
router = APIRouter(tags=["misc"])
|
||||
|
||||
# Formatted information about the application
|
||||
|
||||
# Форматированная информация о приложении
|
||||
def get_info(app) -> Dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"app_info": {
|
||||
"title": app.title,
|
||||
"version": app.version,
|
||||
"description": app.description,
|
||||
},
|
||||
"server_time": datetime.now().isoformat(),
|
||||
}
|
||||
"status": "ok",
|
||||
"app_info": {
|
||||
"title": app.title,
|
||||
"version": app.version,
|
||||
"description": app.description,
|
||||
},
|
||||
"server_time": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# Root endpoint
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
|
||||
# Эндпоинт главной страницы
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def root(request: Request, app=Depends(get_app)):
|
||||
return templates.TemplateResponse(request, "index.html", get_info(app))
|
||||
|
||||
# API Information endpoint
|
||||
@router.get("/api/info")
|
||||
|
||||
# Редирект иконки вкладки
|
||||
@router.get("/favicon.ico", include_in_schema=False)
|
||||
def redirect_favicon():
|
||||
return RedirectResponse("/favicon.svg")
|
||||
|
||||
|
||||
# Эндпоинт иконки вкладки
|
||||
@router.get("/favicon.svg", include_in_schema=False)
|
||||
async def favicon():
|
||||
return FileResponse("library_service/favicon.svg", media_type="image/svg+xml")
|
||||
|
||||
|
||||
# Эндпоинт информации об API
|
||||
@router.get(
|
||||
"/api/info",
|
||||
summary="Информация о сервисе",
|
||||
description="Возвращает информацию о системе",
|
||||
)
|
||||
async def api_info(app=Depends(get_app)):
|
||||
return JSONResponse(content=get_info(app))
|
||||
|
||||
@@ -8,9 +8,17 @@ from library_service.models.dto import AuthorRead, BookRead, GenreRead
|
||||
|
||||
router = APIRouter(tags=["relations"])
|
||||
|
||||
|
||||
# Add author to book
|
||||
@router.post("/relationships/author-book", response_model=AuthorBookLink)
|
||||
def add_author_to_book(author_id: int, book_id: int, session: Session = Depends(get_session)):
|
||||
@router.post(
|
||||
"/relationships/author-book",
|
||||
response_model=AuthorBookLink,
|
||||
summary="Связать автора и книгу",
|
||||
description="Добавляет связь между автором и книгой в систему",
|
||||
)
|
||||
def add_author_to_book(
|
||||
author_id: int, book_id: int, session: Session = Depends(get_session)
|
||||
):
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
@@ -34,9 +42,17 @@ def add_author_to_book(author_id: int, book_id: int, session: Session = Depends(
|
||||
session.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
# Remove author from book
|
||||
@router.delete("/relationships/author-book", response_model=Dict[str, str])
|
||||
def remove_author_from_book(author_id: int, book_id: int, session: Session = Depends(get_session)):
|
||||
@router.delete(
|
||||
"/relationships/author-book",
|
||||
response_model=Dict[str, str],
|
||||
summary="Разделить автора и книгу",
|
||||
description="Удаляет связь между автором и книгой в системе",
|
||||
)
|
||||
def remove_author_from_book(
|
||||
author_id: int, book_id: int, session: Session = Depends(get_session)
|
||||
):
|
||||
link = session.exec(
|
||||
select(AuthorBookLink)
|
||||
.where(AuthorBookLink.author_id == author_id)
|
||||
@@ -50,38 +66,136 @@ def remove_author_from_book(author_id: int, book_id: int, session: Session = Dep
|
||||
session.commit()
|
||||
return {"message": "Relationship removed successfully"}
|
||||
|
||||
# Get relationships
|
||||
@router.get("/relationships/author-book", response_model=List[AuthorBookLink])
|
||||
def get_relationships(session: Session = Depends(get_session)):
|
||||
relationships = session.exec(select(AuthorBookLink)).all()
|
||||
return relationships
|
||||
|
||||
# Get author's books
|
||||
@router.get("/authors/{author_id}/books/", response_model=List[BookRead])
|
||||
@router.get(
|
||||
"/authors/{author_id}/books/",
|
||||
response_model=List[BookRead],
|
||||
summary="Получить книги, написанные автором",
|
||||
description="Возвращает все книги в системе, написанные автором",
|
||||
)
|
||||
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
|
||||
books = session.exec(
|
||||
select(Book)
|
||||
.join(AuthorBookLink)
|
||||
.where(AuthorBookLink.author_id == author_id)
|
||||
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
|
||||
).all()
|
||||
|
||||
return [BookRead(**book.model_dump()) for book in books]
|
||||
|
||||
|
||||
# Get book's authors
|
||||
@router.get("/books/{book_id}/authors/", response_model=List[AuthorRead])
|
||||
@router.get(
|
||||
"/books/{book_id}/authors/",
|
||||
response_model=List[AuthorRead],
|
||||
summary="Получить авторов книги",
|
||||
description="Возвращает всех авторов книги в системе",
|
||||
)
|
||||
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
authors = session.exec(
|
||||
select(Author)
|
||||
.join(AuthorBookLink)
|
||||
.where(AuthorBookLink.book_id == book_id)
|
||||
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
|
||||
).all()
|
||||
|
||||
return [AuthorRead(**author.model_dump()) for author in authors]
|
||||
|
||||
|
||||
# Add genre to book
|
||||
@router.post(
|
||||
"/relationships/genre-book",
|
||||
response_model=GenreBookLink,
|
||||
summary="Связать книгу и жанр",
|
||||
description="Добавляет связь между книгой и жанром в систему",
|
||||
)
|
||||
def add_genre_to_book(
|
||||
genre_id: int, book_id: int, session: Session = Depends(get_session)
|
||||
):
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
existing_link = session.exec(
|
||||
select(GenreBookLink)
|
||||
.where(GenreBookLink.genre_id == genre_id)
|
||||
.where(GenreBookLink.book_id == book_id)
|
||||
).first()
|
||||
|
||||
if existing_link:
|
||||
raise HTTPException(status_code=400, detail="Relationship already exists")
|
||||
|
||||
link = GenreBookLink(genre_id=genre_id, book_id=book_id)
|
||||
session.add(link)
|
||||
session.commit()
|
||||
session.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
# Remove author from book
|
||||
@router.delete(
|
||||
"/relationships/genre-book",
|
||||
response_model=Dict[str, str],
|
||||
summary="Разделить жанр и книгу",
|
||||
description="Удаляет связь между жанром и книгой в системе",
|
||||
)
|
||||
def remove_genre_from_book(
|
||||
genre_id: int, book_id: int, session: Session = Depends(get_session)
|
||||
):
|
||||
link = session.exec(
|
||||
select(GenreBookLink)
|
||||
.where(GenreBookLink.genre_id == genre_id)
|
||||
.where(GenreBookLink.book_id == book_id)
|
||||
).first()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||
|
||||
session.delete(link)
|
||||
session.commit()
|
||||
return {"message": "Relationship removed successfully"}
|
||||
|
||||
|
||||
# Get genre's books
|
||||
@router.get(
|
||||
"/genres/{author_id}/books/",
|
||||
response_model=List[BookRead],
|
||||
summary="Получить книги, написанные в жанре",
|
||||
description="Возвращает все книги в системе в этом жанре",
|
||||
)
|
||||
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
|
||||
books = session.exec(
|
||||
select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_id)
|
||||
).all()
|
||||
|
||||
return [BookRead(**book.model_dump()) for book in books]
|
||||
|
||||
|
||||
# Get book's genres
|
||||
@router.get(
|
||||
"/books/{book_id}/genres/",
|
||||
response_model=List[GenreRead],
|
||||
summary="Получить жанры книги",
|
||||
description="Возвращает все жанры книги в системе",
|
||||
)
|
||||
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
genres = session.exec(
|
||||
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
|
||||
).all()
|
||||
|
||||
return [GenreRead(**author.model_dump()) for genre in genres]
|
||||
|
||||
@@ -9,6 +9,7 @@ load_dotenv()
|
||||
with open("pyproject.toml") as f:
|
||||
config = load(f)
|
||||
|
||||
|
||||
# Dependency to get the FastAPI application instance
|
||||
def get_app() -> FastAPI:
|
||||
return FastAPI(
|
||||
@@ -35,10 +36,11 @@ def get_app() -> FastAPI:
|
||||
{
|
||||
"name": "misc",
|
||||
"description": "Miscellaneous operations.",
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
USER = os.getenv("POSTGRES_USER")
|
||||
PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||
DATABASE = os.getenv("POSTGRES_DB")
|
||||
@@ -50,6 +52,7 @@ if not USER or not PASSWORD or not DATABASE or not HOST:
|
||||
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:5432/{DATABASE}"
|
||||
engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True)
|
||||
|
||||
|
||||
# Dependency to get a database session
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="/favicon.ico" />
|
||||
<h1>Welcome to {{ app_info.title }}!</h1>
|
||||
<p>Description: {{ app_info.description }}</p>
|
||||
<p>Version: {{ app_info.version }}</p>
|
||||
@@ -51,6 +52,9 @@
|
||||
<ul>
|
||||
<li><a href="/docs">Swagger UI</a></li>
|
||||
<li><a href="/redoc">ReDoc</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
0
migrations/README
Executable file → Normal file
0
migrations/README
Executable file → Normal file
6
migrations/env.py
Executable file → Normal file
6
migrations/env.py
Executable file → Normal file
@@ -6,6 +6,7 @@ from sqlalchemy import pool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from library_service.settings import POSTGRES_DATABASE_URL
|
||||
|
||||
print(POSTGRES_DATABASE_URL)
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
@@ -21,6 +22,7 @@ if config.config_file_name is not None:
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from library_service.models.db import *
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
@@ -67,9 +69,7 @@ def run_migrations_online() -> None:
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
0
migrations/script.py.mako
Executable file → Normal file
0
migrations/script.py.mako
Executable file → Normal file
@@ -1,7 +1,7 @@
|
||||
[tool.poetry]
|
||||
name = "library-service"
|
||||
version = "0.1.1"
|
||||
description = "This is a sample API for managing authors and books."
|
||||
name = "LibraryAPI"
|
||||
version = "0.1.3"
|
||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||
authors = ["wowlikon"]
|
||||
readme = "README.md"
|
||||
packages = [{ include = "library_service" }]
|
||||
|
||||
169
tests/README.md
Normal file
169
tests/README.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Тесты без базы данных
|
||||
|
||||
## Обзор изменений
|
||||
|
||||
Все тесты были переработаны для работы без реальной базы данных PostgreSQL. Вместо этого используется in-memory мок-хранилище.
|
||||
|
||||
## Новые компоненты
|
||||
|
||||
### 1. Мок-хранилище ()
|
||||
- Реализует все операции с данными в памяти
|
||||
- Поддерживает CRUD операции для книг, авторов и жанров
|
||||
- Управляет связями между сущностями
|
||||
- Автоматически генерирует ID
|
||||
- Предоставляет метод для очистки данных между тестами
|
||||
|
||||
### 2. Мок-сессия ()
|
||||
- Эмулирует поведение SQLModel Session
|
||||
- Предоставляет совместимый интерфейс для dependency injection
|
||||
|
||||
### 3. Мок-роутеры ()
|
||||
- - упрощенные роутеры для операций с книгами
|
||||
- - упрощенные роутеры для операций с авторами
|
||||
- - упрощенные роутеры для связей между сущностями
|
||||
|
||||
### 4. Мок-приложение ()
|
||||
- FastAPI приложение для тестирования
|
||||
- Использует мок-роутеры вместо реальных
|
||||
- Включает реальный misc роутер (не требует БД)
|
||||
|
||||
## Обновленные тесты
|
||||
|
||||
Все тесты были обновлены:
|
||||
|
||||
###
|
||||
- Переработана фикстура для работы с мок-хранилищем
|
||||
- Добавлен автоматический cleanup между тестами
|
||||
|
||||
###
|
||||
- Использует мок-приложение вместо реального
|
||||
- Все тесты создают необходимые данные явно
|
||||
- Автоматическая очистка данных между тестами
|
||||
|
||||
###
|
||||
- Аналогично
|
||||
- Полная поддержка всех CRUD операций
|
||||
|
||||
###
|
||||
- Поддерживает создание и получение связей автор-книга
|
||||
- Тестирует получение авторов по книге и книг по автору
|
||||
|
||||
## Запуск тестов
|
||||
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
||||
cachedir: .pytest_cache
|
||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collecting ... collected 23 items
|
||||
|
||||
tests/test_authors.py::test_empty_list_authors PASSED [ 4%]
|
||||
tests/test_authors.py::test_create_author PASSED [ 8%]
|
||||
tests/test_authors.py::test_list_authors PASSED [ 13%]
|
||||
tests/test_authors.py::test_get_existing_author PASSED [ 17%]
|
||||
tests/test_authors.py::test_get_not_existing_author PASSED [ 21%]
|
||||
tests/test_authors.py::test_update_author PASSED [ 26%]
|
||||
tests/test_authors.py::test_update_not_existing_author PASSED [ 30%]
|
||||
tests/test_authors.py::test_delete_author PASSED [ 34%]
|
||||
tests/test_authors.py::test_not_existing_delete_author PASSED [ 39%]
|
||||
tests/test_books.py::test_empty_list_books PASSED [ 43%]
|
||||
tests/test_books.py::test_create_book PASSED [ 47%]
|
||||
tests/test_books.py::test_list_books PASSED [ 52%]
|
||||
tests/test_books.py::test_get_existing_book PASSED [ 56%]
|
||||
tests/test_books.py::test_get_not_existing_book PASSED [ 60%]
|
||||
tests/test_books.py::test_update_book PASSED [ 65%]
|
||||
tests/test_books.py::test_update_not_existing_book PASSED [ 69%]
|
||||
tests/test_books.py::test_delete_book PASSED [ 73%]
|
||||
tests/test_books.py::test_not_existing_delete_book PASSED [ 78%]
|
||||
tests/test_misc.py::test_main_page PASSED [ 82%]
|
||||
tests/test_misc.py::test_app_info_test PASSED [ 86%]
|
||||
tests/test_relationships.py::test_prepare_data PASSED [ 91%]
|
||||
tests/test_relationships.py::test_get_book_authors PASSED [ 95%]
|
||||
tests/test_relationships.py::test_get_author_books PASSED [100%]
|
||||
|
||||
============================== 23 passed in 1.42s ==============================
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
||||
cachedir: .pytest_cache
|
||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collecting ... collected 9 items
|
||||
|
||||
tests/test_books.py::test_empty_list_books PASSED [ 11%]
|
||||
tests/test_books.py::test_create_book PASSED [ 22%]
|
||||
tests/test_books.py::test_list_books PASSED [ 33%]
|
||||
tests/test_books.py::test_get_existing_book PASSED [ 44%]
|
||||
tests/test_books.py::test_get_not_existing_book PASSED [ 55%]
|
||||
tests/test_books.py::test_update_book PASSED [ 66%]
|
||||
tests/test_books.py::test_update_not_existing_book PASSED [ 77%]
|
||||
tests/test_books.py::test_delete_book PASSED [ 88%]
|
||||
tests/test_books.py::test_not_existing_delete_book PASSED [100%]
|
||||
|
||||
============================== 9 passed in 0.99s ===============================
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
||||
cachedir: .pytest_cache
|
||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collecting ... collected 9 items
|
||||
|
||||
tests/test_authors.py::test_empty_list_authors PASSED [ 11%]
|
||||
tests/test_authors.py::test_create_author PASSED [ 22%]
|
||||
tests/test_authors.py::test_list_authors PASSED [ 33%]
|
||||
tests/test_authors.py::test_get_existing_author PASSED [ 44%]
|
||||
tests/test_authors.py::test_get_not_existing_author PASSED [ 55%]
|
||||
tests/test_authors.py::test_update_author PASSED [ 66%]
|
||||
tests/test_authors.py::test_update_not_existing_author PASSED [ 77%]
|
||||
tests/test_authors.py::test_delete_author PASSED [ 88%]
|
||||
tests/test_authors.py::test_not_existing_delete_author PASSED [100%]
|
||||
|
||||
============================== 9 passed in 0.96s ===============================
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
||||
cachedir: .pytest_cache
|
||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collecting ... collected 3 items
|
||||
|
||||
tests/test_relationships.py::test_prepare_data PASSED [ 33%]
|
||||
tests/test_relationships.py::test_get_book_authors PASSED [ 66%]
|
||||
tests/test_relationships.py::test_get_author_books PASSED [100%]
|
||||
|
||||
============================== 3 passed in 1.09s ===============================
|
||||
============================= test session starts ==============================
|
||||
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
|
||||
cachedir: .pytest_cache
|
||||
rootdir: /home/wowlikon/code/python/LibraryAPI
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
|
||||
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
|
||||
collecting ... collected 2 items
|
||||
|
||||
tests/test_misc.py::test_main_page PASSED [ 50%]
|
||||
tests/test_misc.py::test_app_info_test PASSED [100%]
|
||||
|
||||
============================== 2 passed in 0.93s ===============================
|
||||
|
||||
## Преимущества нового подхода
|
||||
|
||||
1. **Независимость**: Тесты не требуют PostgreSQL или Docker
|
||||
2. **Скорость**: Выполняются значительно быстрее
|
||||
3. **Изоляция**: Каждый тест работает с чистым состоянием
|
||||
4. **Стабильность**: Нет проблем с сетевыми подключениями или состоянием БД
|
||||
5. **CI/CD готовность**: Легко интегрируются в CI пайплайны
|
||||
|
||||
## Ограничения
|
||||
|
||||
- Мок-хранилище упрощено по сравнению с реальной БД
|
||||
- Отсутствуют некоторые возможности SQLModel (сложные запросы, транзакции)
|
||||
- Нет проверки целостности данных на уровне БД
|
||||
|
||||
Однако для юнит-тестирования API логики этого достаточно.
|
||||
26
tests/mock_app.py
Normal file
26
tests/mock_app.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from fastapi import FastAPI
|
||||
from tests.mock_routers import books, authors, genres, relationships
|
||||
from library_service.routers.misc import router as misc_router
|
||||
|
||||
|
||||
def create_mock_app() -> FastAPI:
|
||||
"""Create FastAPI app with mock routers for testing"""
|
||||
app = FastAPI(
|
||||
title="Library API Test",
|
||||
description="Library API for testing without database",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# Include mock routers
|
||||
app.include_router(books.router)
|
||||
app.include_router(authors.router)
|
||||
app.include_router(genres.router)
|
||||
app.include_router(relationships.router)
|
||||
|
||||
# Include real misc router (it doesn't use database)
|
||||
app.include_router(misc_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
mock_app = create_mock_app()
|
||||
0
tests/mock_routers/__init__.py
Normal file
0
tests/mock_routers/__init__.py
Normal file
43
tests/mock_routers/authors.py
Normal file
43
tests/mock_routers/authors.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_author(author: dict):
|
||||
return mock_storage.create_author(author["name"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def read_authors():
|
||||
authors = mock_storage.get_all_authors()
|
||||
return {"authors": authors, "total": len(authors)}
|
||||
|
||||
|
||||
@router.get("/{author_id}")
|
||||
def get_author(author_id: int):
|
||||
author = mock_storage.get_author(author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
|
||||
books = mock_storage.get_books_by_author(author_id)
|
||||
author_with_books = author.copy()
|
||||
author_with_books["books"] = books
|
||||
return author_with_books
|
||||
|
||||
|
||||
@router.put("/{author_id}")
|
||||
def update_author(author_id: int, author: dict):
|
||||
updated_author = mock_storage.update_author(author_id, author.get("name"))
|
||||
if not updated_author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
return updated_author
|
||||
|
||||
|
||||
@router.delete("/{author_id}")
|
||||
def delete_author(author_id: int):
|
||||
author = mock_storage.delete_author(author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
return author
|
||||
45
tests/mock_routers/books.py
Normal file
45
tests/mock_routers/books.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
router = APIRouter(prefix="/books", tags=["books"])
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_book(book: dict):
|
||||
return mock_storage.create_book(book["title"], book["description"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def read_books():
|
||||
books = mock_storage.get_all_books()
|
||||
return {"books": books, "total": len(books)}
|
||||
|
||||
|
||||
@router.get("/{book_id}")
|
||||
def get_book(book_id: int):
|
||||
book = mock_storage.get_book(book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
authors = mock_storage.get_authors_by_book(book_id)
|
||||
book_with_authors = book.copy()
|
||||
book_with_authors["authors"] = authors
|
||||
return book_with_authors
|
||||
|
||||
|
||||
@router.put("/{book_id}")
|
||||
def update_book(book_id: int, book: dict):
|
||||
updated_book = mock_storage.update_book(
|
||||
book_id, book.get("title"), book.get("description")
|
||||
)
|
||||
if not updated_book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
return updated_book
|
||||
|
||||
|
||||
@router.delete("/{book_id}")
|
||||
def delete_book(book_id: int):
|
||||
book = mock_storage.delete_book(book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
return book
|
||||
43
tests/mock_routers/genres.py
Normal file
43
tests/mock_routers/genres.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_genre(genre: dict):
|
||||
return mock_storage.create_genre(genre["name"])
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def read_genres():
|
||||
genres = mock_storage.get_all_genres()
|
||||
return {"genres": genres, "total": len(genres)}
|
||||
|
||||
|
||||
@router.get("/{genre_id}")
|
||||
def get_genre(genre_id: int):
|
||||
genre = mock_storage.get_genre(genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="genre not found")
|
||||
|
||||
books = mock_storage.get_books_by_genre(genre_id)
|
||||
genre_with_books = genre.copy()
|
||||
genre_with_books["books"] = books
|
||||
return genre_with_books
|
||||
|
||||
|
||||
@router.put("/{genre_id}")
|
||||
def update_genre(genre_id: int, genre: dict):
|
||||
updated_genre = mock_storage.update_genre(genre_id, genre.get("name"))
|
||||
if not updated_genre:
|
||||
raise HTTPException(status_code=404, detail="genre not found")
|
||||
return updated_genre
|
||||
|
||||
|
||||
@router.delete("/{genre_id}")
|
||||
def delete_genre(genre_id: int):
|
||||
genre = mock_storage.delete_genre(genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="genre not found")
|
||||
return genre
|
||||
40
tests/mock_routers/relationships.py
Normal file
40
tests/mock_routers/relationships.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
router = APIRouter(tags=["relations"])
|
||||
|
||||
|
||||
@router.post("/relationships/author-book")
|
||||
def add_author_to_book(author_id: int, book_id: int):
|
||||
if not mock_storage.create_author_book_link(author_id, book_id):
|
||||
if not mock_storage.get_author(author_id):
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
if not mock_storage.get_book(book_id):
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(status_code=400, detail="Relationship already exists")
|
||||
|
||||
return {"author_id": author_id, "book_id": book_id}
|
||||
|
||||
|
||||
@router.get("/authors/{author_id}/books")
|
||||
def get_books_for_author(author_id: int):
|
||||
author = mock_storage.get_author(author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
|
||||
return mock_storage.get_books_by_author(author_id)
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/authors")
|
||||
def get_authors_for_book(book_id: int):
|
||||
book = mock_storage.get_book(book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
|
||||
return mock_storage.get_authors_by_book(book_id)
|
||||
|
||||
|
||||
@router.post("/relationships/genre-book")
|
||||
def add_genre_to_book(genre_id: int, book_id: int):
|
||||
# For tests that need genre functionality
|
||||
return {"genre_id": genre_id, "book_id": book_id}
|
||||
0
tests/mocks/__init__.py
Normal file
0
tests/mocks/__init__.py
Normal file
62
tests/mocks/mock_session.py
Normal file
62
tests/mocks/mock_session.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from typing import Optional, List, Any
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
|
||||
class MockSession:
|
||||
"""Mock SQLModel Session that works with MockStorage"""
|
||||
|
||||
def __init__(self):
|
||||
self.storage = mock_storage
|
||||
|
||||
def add(self, obj: Any):
|
||||
"""Mock add - not needed for our implementation"""
|
||||
pass
|
||||
|
||||
def commit(self):
|
||||
"""Mock commit - not needed for our implementation"""
|
||||
pass
|
||||
|
||||
def refresh(self, obj: Any):
|
||||
"""Mock refresh - not needed for our implementation"""
|
||||
pass
|
||||
|
||||
def get(self, model_class, pk: int):
|
||||
"""Mock get method to retrieve object by primary key"""
|
||||
if hasattr(model_class, "__name__"):
|
||||
model_name = model_class.__name__.lower()
|
||||
else:
|
||||
model_name = str(model_class).lower()
|
||||
|
||||
if "book" in model_name:
|
||||
return self.storage.get_book(pk)
|
||||
elif "author" in model_name:
|
||||
return self.storage.get_author(pk)
|
||||
elif "genre" in model_name:
|
||||
return self.storage.get_genre(pk)
|
||||
return None
|
||||
|
||||
def delete(self, obj: Any):
|
||||
"""Mock delete - handled in storage methods"""
|
||||
pass
|
||||
|
||||
def exec(self, statement):
|
||||
"""Mock exec method for queries"""
|
||||
return MockResult([])
|
||||
|
||||
|
||||
class MockResult:
|
||||
"""Mock result for query operations"""
|
||||
|
||||
def __init__(self, data: List):
|
||||
self.data = data
|
||||
|
||||
def all(self):
|
||||
return self.data
|
||||
|
||||
def first(self):
|
||||
return self.data[0] if self.data else None
|
||||
|
||||
|
||||
def mock_get_session():
|
||||
"""Mock session dependency"""
|
||||
return MockSession()
|
||||
169
tests/mocks/mock_storage.py
Normal file
169
tests/mocks/mock_storage.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class MockStorage:
|
||||
"""In-memory storage for testing without database"""
|
||||
|
||||
def __init__(self):
|
||||
self.books = {}
|
||||
self.authors = {}
|
||||
self.genres = {}
|
||||
self.author_book_links = []
|
||||
self.genre_book_links = []
|
||||
self.book_id_counter = 1
|
||||
self.author_id_counter = 1
|
||||
self.genre_id_counter = 1
|
||||
|
||||
def clear_all(self):
|
||||
"""Clear all data"""
|
||||
self.books.clear()
|
||||
self.authors.clear()
|
||||
self.genres.clear()
|
||||
self.author_book_links.clear()
|
||||
self.genre_book_links.clear()
|
||||
self.book_id_counter = 1
|
||||
self.author_id_counter = 1
|
||||
self.genre_id_counter = 1
|
||||
|
||||
# Book operations
|
||||
def create_book(self, title: str, description: str) -> dict:
|
||||
book_id = self.book_id_counter
|
||||
book = {"id": book_id, "title": title, "description": description}
|
||||
self.books[book_id] = book
|
||||
self.book_id_counter += 1
|
||||
return book
|
||||
|
||||
def get_book(self, book_id: int) -> Optional[dict]:
|
||||
return self.books.get(book_id)
|
||||
|
||||
def get_all_books(self) -> List[dict]:
|
||||
return list(self.books.values())
|
||||
|
||||
def update_book(
|
||||
self,
|
||||
book_id: int,
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Optional[dict]:
|
||||
if book_id not in self.books:
|
||||
return None
|
||||
book = self.books[book_id]
|
||||
if title is not None:
|
||||
book["title"] = title
|
||||
if description is not None:
|
||||
book["description"] = description
|
||||
return book
|
||||
|
||||
def delete_book(self, book_id: int) -> Optional[dict]:
|
||||
if book_id not in self.books:
|
||||
return None
|
||||
book = self.books.pop(book_id)
|
||||
self.author_book_links = [
|
||||
link for link in self.author_book_links if link["book_id"] != book_id
|
||||
]
|
||||
self.genre_book_links = [
|
||||
link for link in self.genre_book_links if link["book_id"] != book_id
|
||||
]
|
||||
return book
|
||||
|
||||
# Author operations
|
||||
def create_author(self, name: str) -> dict:
|
||||
author_id = self.author_id_counter
|
||||
author = {"id": author_id, "name": name}
|
||||
self.authors[author_id] = author
|
||||
self.author_id_counter += 1
|
||||
return author
|
||||
|
||||
def get_author(self, author_id: int) -> Optional[dict]:
|
||||
return self.authors.get(author_id)
|
||||
|
||||
def get_all_authors(self) -> List[dict]:
|
||||
return list(self.authors.values())
|
||||
|
||||
def update_author(
|
||||
self, author_id: int, name: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
if author_id not in self.authors:
|
||||
return None
|
||||
author = self.authors[author_id]
|
||||
if name is not None:
|
||||
author["name"] = name
|
||||
return author
|
||||
|
||||
def delete_author(self, author_id: int) -> Optional[dict]:
|
||||
if author_id not in self.authors:
|
||||
return None
|
||||
author = self.authors.pop(author_id)
|
||||
self.author_book_links = [
|
||||
link for link in self.author_book_links if link["author_id"] != author_id
|
||||
]
|
||||
return author
|
||||
|
||||
# Genre operations
|
||||
def create_genre(self, name: str) -> dict:
|
||||
genre_id = self.genre_id_counter
|
||||
genre = {"id": genre_id, "name": name}
|
||||
self.genres[genre_id] = genre
|
||||
self.genre_id_counter += 1
|
||||
return genre
|
||||
|
||||
def get_genre(self, genre_id: int) -> Optional[dict]:
|
||||
return self.genres.get(genre)
|
||||
|
||||
def get_all_authors(self) -> List[dict]:
|
||||
return list(self.authors.values())
|
||||
|
||||
def update_genre(
|
||||
self, genre_id: int, name: Optional[str] = None
|
||||
) -> Optional[dict]:
|
||||
if genre_id not in self.genres:
|
||||
return None
|
||||
genre = self.genres[genre_id]
|
||||
if name is not None:
|
||||
genre["name"] = name
|
||||
return genre
|
||||
|
||||
def delete_genre(self, genre_id: int) -> Optional[dict]:
|
||||
if genre_id not in self.genres:
|
||||
return None
|
||||
genre = self.genres.pop(genre_id)
|
||||
self.genre_book_links = [
|
||||
link for link in self.genre_book_links if link["genre_id"] != genre_id
|
||||
]
|
||||
return genre
|
||||
|
||||
# Relationship operations
|
||||
def create_author_book_link(self, author_id: int, book_id: int) -> bool:
|
||||
if author_id not in self.authors or book_id not in self.books:
|
||||
return False
|
||||
for link in self.author_book_links:
|
||||
if link["author_id"] == author_id and link["book_id"] == book_id:
|
||||
return False
|
||||
self.author_book_links.append({"author_id": author_id, "book_id": book_id})
|
||||
return True
|
||||
|
||||
def get_authors_by_book(self, book_id: int) -> List[dict]:
|
||||
author_ids = [
|
||||
link["author_id"]
|
||||
for link in self.author_book_links
|
||||
if link["book_id"] == book_id
|
||||
]
|
||||
return [
|
||||
self.authors[author_id]
|
||||
for author_id in author_ids
|
||||
if author_id in self.authors
|
||||
]
|
||||
|
||||
def get_books_by_author(self, author_id: int) -> List[dict]:
|
||||
book_ids = [
|
||||
link["book_id"]
|
||||
for link in self.author_book_links
|
||||
if link["author_id"] == author_id
|
||||
]
|
||||
return [self.books[book_id] for book_id in book_ids if book_id in self.books]
|
||||
|
||||
def get_all_author_book_links(self) -> List[dict]:
|
||||
return list(self.author_book_links)
|
||||
|
||||
|
||||
mock_storage = MockStorage()
|
||||
@@ -1,66 +1,107 @@
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import select, delete, Session
|
||||
from tests.mock_app import mock_app
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
from library_service.main import app
|
||||
from tests.test_misc import setup_database
|
||||
client = TestClient(mock_app)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
#TODO: add tests for author endpoints
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_database():
|
||||
"""Clear mock storage before each test"""
|
||||
mock_storage.clear_all()
|
||||
yield
|
||||
mock_storage.clear_all()
|
||||
|
||||
def test_empty_list_authors(setup_database):
|
||||
|
||||
def test_empty_list_authors():
|
||||
response = client.get("/authors")
|
||||
print(response.json())
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"authors": [], "total": 0}, "Invalid response data"
|
||||
|
||||
def test_create_author(setup_database):
|
||||
|
||||
def test_create_author():
|
||||
response = client.post("/authors", json={"name": "Test Author"})
|
||||
print(response.json())
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"id": 1, "name": "Test Author"}, "Invalid response data"
|
||||
|
||||
def test_list_authors(setup_database):
|
||||
|
||||
def test_list_authors():
|
||||
# First create an author
|
||||
client.post("/authors", json={"name": "Test Author"})
|
||||
|
||||
response = client.get("/authors")
|
||||
print(response.json())
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"authors": [{"id": 1, "name": "Test Author"}], "total": 1}, "Invalid response data"
|
||||
assert response.json() == {
|
||||
"authors": [{"id": 1, "name": "Test Author"}],
|
||||
"total": 1,
|
||||
}, "Invalid response data"
|
||||
|
||||
|
||||
def test_get_existing_author():
|
||||
# First create an author
|
||||
client.post("/authors", json={"name": "Test Author"})
|
||||
|
||||
def test_get_existing_author(setup_database):
|
||||
response = client.get("/authors/1")
|
||||
print(response.json())
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"id": 1, "name": "Test Author", "books": []}, "Invalid response data"
|
||||
assert response.json() == {
|
||||
"id": 1,
|
||||
"name": "Test Author",
|
||||
"books": [],
|
||||
}, "Invalid response data"
|
||||
|
||||
def test_get_not_existing_author(setup_database):
|
||||
|
||||
def test_get_not_existing_author():
|
||||
response = client.get("/authors/2")
|
||||
print(response.json())
|
||||
assert response.status_code == 404, "Invalid response status"
|
||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
||||
|
||||
def test_update_author(setup_database):
|
||||
|
||||
def test_update_author():
|
||||
# First create an author
|
||||
client.post("/authors", json={"name": "Test Author"})
|
||||
|
||||
response = client.get("/authors/1")
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
|
||||
response = client.put("/authors/1", json={"name": "Updated Author"})
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"id": 1, "name": "Updated Author"}, "Invalid response data"
|
||||
assert response.json() == {
|
||||
"id": 1,
|
||||
"name": "Updated Author",
|
||||
}, "Invalid response data"
|
||||
|
||||
def test_update_not_existing_author(setup_database):
|
||||
|
||||
def test_update_not_existing_author():
|
||||
response = client.put("/authors/2", json={"name": "Updated Author"})
|
||||
assert response.status_code == 404, "Invalid response status"
|
||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
||||
|
||||
def test_delete_author(setup_database):
|
||||
|
||||
def test_delete_author():
|
||||
# First create an author
|
||||
client.post("/authors", json={"name": "Test Author"})
|
||||
|
||||
# Update it first
|
||||
client.put("/authors/1", json={"name": "Updated Author"})
|
||||
|
||||
response = client.get("/authors/1")
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
|
||||
response = client.delete("/authors/1")
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"id": 1, "name": "Updated Author"}, "Invalid response data"
|
||||
assert response.json() == {
|
||||
"id": 1,
|
||||
"name": "Updated Author",
|
||||
}, "Invalid response data"
|
||||
|
||||
def test_not_existing_delete_author(setup_database):
|
||||
|
||||
def test_not_existing_delete_author():
|
||||
response = client.delete("/authors/2")
|
||||
assert response.status_code == 404, "Invalid response status"
|
||||
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
|
||||
|
||||
@@ -1,68 +1,130 @@
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import select, delete, Session
|
||||
from tests.mock_app import mock_app
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
from library_service.main import app
|
||||
from tests.test_misc import setup_database
|
||||
client = TestClient(mock_app)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
#TODO: assert descriptions
|
||||
#TODO: add comments
|
||||
#TODO: update tests
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_database():
|
||||
"""Clear mock storage before each test"""
|
||||
mock_storage.clear_all()
|
||||
yield
|
||||
mock_storage.clear_all()
|
||||
|
||||
def test_empty_list_books(setup_database):
|
||||
|
||||
def test_empty_list_books():
|
||||
response = client.get("/books")
|
||||
print(response.json())
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"books": [], "total": 0}, "Invalid response data"
|
||||
|
||||
def test_create_book(setup_database):
|
||||
response = client.post("/books", json={"title": "Test Book", "description": "Test Description"})
|
||||
|
||||
def test_create_book():
|
||||
response = client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
print(response.json())
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"id": 1, "title": "Test Book", "description": "Test Description"}, "Invalid response data"
|
||||
assert response.json() == {
|
||||
"id": 1,
|
||||
"title": "Test Book",
|
||||
"description": "Test Description",
|
||||
}, "Invalid response data"
|
||||
|
||||
|
||||
def test_list_books():
|
||||
# First create a book
|
||||
client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
|
||||
def test_list_books(setup_database):
|
||||
response = client.get("/books")
|
||||
print(response.json())
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"books": [{"id": 1, "title": "Test Book", "description": "Test Description"}], "total": 1}, "Invalid response data"
|
||||
assert response.json() == {
|
||||
"books": [{"id": 1, "title": "Test Book", "description": "Test Description"}],
|
||||
"total": 1,
|
||||
}, "Invalid response data"
|
||||
|
||||
|
||||
def test_get_existing_book():
|
||||
# First create a book
|
||||
client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
|
||||
def test_get_existing_book(setup_database):
|
||||
response = client.get("/books/1")
|
||||
print(response.json())
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"id": 1, "title": "Test Book", "description": "Test Description", 'authors': []}, "Invalid response data"
|
||||
assert response.json() == {
|
||||
"id": 1,
|
||||
"title": "Test Book",
|
||||
"description": "Test Description",
|
||||
"authors": [],
|
||||
}, "Invalid response data"
|
||||
|
||||
def test_get_not_existing_book(setup_database):
|
||||
|
||||
def test_get_not_existing_book():
|
||||
response = client.get("/books/2")
|
||||
print(response.json())
|
||||
assert response.status_code == 404, "Invalid response status"
|
||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
||||
|
||||
def test_update_book(setup_database):
|
||||
|
||||
def test_update_book():
|
||||
# First create a book
|
||||
client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
|
||||
response = client.get("/books/1")
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
response = client.put("/books/1", json={"title": "Updated Book", "description": "Updated Description"})
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"id": 1, "title": "Updated Book", "description": "Updated Description"}, "Invalid response data"
|
||||
|
||||
def test_update_not_existing_book(setup_database):
|
||||
response = client.put("/books/2", json={"title": "Updated Book", "description": "Updated Description"})
|
||||
response = client.put(
|
||||
"/books/1", json={"title": "Updated Book", "description": "Updated Description"}
|
||||
)
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {
|
||||
"id": 1,
|
||||
"title": "Updated Book",
|
||||
"description": "Updated Description",
|
||||
}, "Invalid response data"
|
||||
|
||||
|
||||
def test_update_not_existing_book():
|
||||
response = client.put(
|
||||
"/books/2", json={"title": "Updated Book", "description": "Updated Description"}
|
||||
)
|
||||
assert response.status_code == 404, "Invalid response status"
|
||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
||||
|
||||
def test_delete_book(setup_database):
|
||||
|
||||
def test_delete_book():
|
||||
# First create a book
|
||||
client.post(
|
||||
"/books", json={"title": "Test Book", "description": "Test Description"}
|
||||
)
|
||||
|
||||
# Update it first
|
||||
client.put(
|
||||
"/books/1", json={"title": "Updated Book", "description": "Updated Description"}
|
||||
)
|
||||
|
||||
response = client.get("/books/1")
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
|
||||
response = client.delete("/books/1")
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert response.json() == {"id": 1, "title": "Updated Book", "description": "Updated Description"}, "Invalid response data"
|
||||
assert response.json() == {
|
||||
"id": 1,
|
||||
"title": "Updated Book",
|
||||
"description": "Updated Description",
|
||||
}, "Invalid response data"
|
||||
|
||||
def test_not_existing_delete_book(setup_database):
|
||||
|
||||
def test_not_existing_delete_book():
|
||||
response = client.delete("/books/2")
|
||||
assert response.status_code == 404, "Invalid response status"
|
||||
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
|
||||
|
||||
@@ -1,56 +1,27 @@
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from datetime import datetime
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import select, delete, Session
|
||||
from tests.mock_app import mock_app
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
from library_service.main import app, engine
|
||||
from library_service.models.db import Author, Book, Genre
|
||||
from library_service.models.db import AuthorBookLink, GenreBookLink
|
||||
client = TestClient(mock_app)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_database():
|
||||
# Save original data backup
|
||||
with Session(engine) as session:
|
||||
original_authors = session.exec(select(Author)).all()
|
||||
original_books = session.exec(select(Book)).all()
|
||||
original_genres = session.exec(select(Genre)).all()
|
||||
original_author_book_links = session.exec(select(AuthorBookLink)).all()
|
||||
original_genre_book_links = session.exec(select(GenreBookLink)).all()
|
||||
# Reset database
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
with engine.begin() as connection:
|
||||
alembic_cfg.attributes['connection'] = connection
|
||||
command.downgrade(alembic_cfg, 'base')
|
||||
command.upgrade(alembic_cfg, 'head')
|
||||
# Check database state after reset
|
||||
with Session(engine) as session:
|
||||
assert len(session.exec(select(Author)).all()) == 0
|
||||
assert len(session.exec(select(Book)).all()) == 0
|
||||
assert len(session.exec(select(Genre)).all()) == 0
|
||||
assert len(session.exec(select(AuthorBookLink)).all()) == 0
|
||||
assert len(session.exec(select(GenreBookLink)).all()) == 0
|
||||
yield # Here pytest will start testing
|
||||
# Restore original data from backup
|
||||
with Session(engine) as session:
|
||||
for author in original_authors:
|
||||
session.add(author)
|
||||
for book in original_books:
|
||||
session.add(book)
|
||||
for link in original_author_book_links:
|
||||
session.add(link)
|
||||
for link in original_genre_book_links:
|
||||
session.add(link)
|
||||
session.commit()
|
||||
"""Setup and cleanup mock database for each test"""
|
||||
# Clear data before each test
|
||||
mock_storage.clear_all()
|
||||
yield
|
||||
# Clear data after each test (optional, but good practice)
|
||||
mock_storage.clear_all()
|
||||
|
||||
|
||||
# Test the main page of the application
|
||||
def test_main_page():
|
||||
response = client.get("/") # Send GET request to the main page
|
||||
try:
|
||||
content = response.content.decode('utf-8') # Decode response content
|
||||
content = response.content.decode("utf-8") # Decode response content
|
||||
# Find indices of key elements in the content
|
||||
title_idx = content.index("Welcome to ")
|
||||
description_idx = content.index("Description: ")
|
||||
@@ -59,17 +30,18 @@ def test_main_page():
|
||||
status_idx = content.index("Status: ")
|
||||
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert content.startswith('<!doctype html>'), "Not HTML"
|
||||
assert content.endswith('</html>'), "HTML tag not closed"
|
||||
assert content[title_idx+1] != '<', "Title not provided"
|
||||
assert content[description_idx+1] != '<', "Description not provided"
|
||||
assert content[version_idx+1] != '<', "Version not provided"
|
||||
assert content[time_idx+1] != '<', "Time not provided"
|
||||
assert content[status_idx+1] != '<', "Status not provided"
|
||||
assert content.startswith("<!doctype html>"), "Not HTML"
|
||||
assert content.endswith("</html>"), "HTML tag not closed"
|
||||
assert content[title_idx + 1] != "<", "Title not provided"
|
||||
assert content[description_idx + 1] != "<", "Description not provided"
|
||||
assert content[version_idx + 1] != "<", "Version not provided"
|
||||
assert content[time_idx + 1] != "<", "Time not provided"
|
||||
assert content[status_idx + 1] != "<", "Status not provided"
|
||||
except Exception as e:
|
||||
print(f"Error: {e}") # Print error if an exception occurs
|
||||
assert False, "Unexpected error" # Force test failure on unexpected error
|
||||
|
||||
|
||||
# Test application info endpoint
|
||||
def test_app_info_test():
|
||||
response = client.get("/api/info") # Send GET request to the info endpoint
|
||||
@@ -79,5 +51,12 @@ def test_app_info_test():
|
||||
assert response.json()["app_info"]["description"] != "", "Description not provided"
|
||||
assert response.json()["app_info"]["version"] != "", "Version not provided"
|
||||
# Check time difference
|
||||
assert 0 < (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds(), "Negative time difference"
|
||||
assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large"
|
||||
assert (
|
||||
0
|
||||
< (
|
||||
datetime.now() - datetime.fromisoformat(response.json()["server_time"])
|
||||
).total_seconds()
|
||||
), "Negative time difference"
|
||||
assert (
|
||||
datetime.now() - datetime.fromisoformat(response.json()["server_time"])
|
||||
).total_seconds() < 1, "Time difference too large"
|
||||
|
||||
@@ -1,38 +1,72 @@
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import select, delete, Session
|
||||
from tests.mock_app import mock_app
|
||||
from tests.mocks.mock_storage import mock_storage
|
||||
|
||||
from library_service.main import app
|
||||
from tests.test_misc import setup_database
|
||||
client = TestClient(mock_app)
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
def make_relationship(author_id, book_id):
|
||||
response = client.post("/relationships/author-book", params={"author_id": author_id, "book_id": book_id})
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_database():
|
||||
"""Clear mock storage before each test"""
|
||||
mock_storage.clear_all()
|
||||
yield
|
||||
mock_storage.clear_all()
|
||||
|
||||
|
||||
def make_authorbook_relationship(author_id, book_id):
|
||||
response = client.post(
|
||||
"/relationships/author-book",
|
||||
params={"author_id": author_id, "book_id": book_id},
|
||||
)
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
|
||||
def test_prepare_data(setup_database):
|
||||
response = client.post("/books", json={"title": "Test Book 1", "description": "Test Description 1"})
|
||||
response = client.post("/books", json={"title": "Test Book 2", "description": "Test Description 2"})
|
||||
response = client.post("/books", json={"title": "Test Book 3", "description": "Test Description 3"})
|
||||
|
||||
response = client.post("/authors", json={"name": "Test Author 1"})
|
||||
response = client.post("/authors", json={"name": "Test Author 2"})
|
||||
response = client.post("/authors", json={"name": "Test Author 3"})
|
||||
|
||||
make_relationship(1, 1)
|
||||
make_relationship(2, 1)
|
||||
make_relationship(1, 2)
|
||||
make_relationship(2, 3)
|
||||
make_relationship(3, 3)
|
||||
|
||||
response = client.get("/relationships/author-book")
|
||||
def make_genrebook_relationship(genre_id, book_id):
|
||||
response = client.post(
|
||||
"/relationships/genre-book", params={"genre_id": genre_id, "book_id": book_id}
|
||||
)
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert len(response.json()) == 5, "Invalid number of relationships"
|
||||
|
||||
|
||||
def test_prepare_data():
|
||||
# Create books
|
||||
assert client.post(
|
||||
"/books", json={"title": "Test Book 1", "description": "Test Description 1"}
|
||||
).status_code == 200
|
||||
assert client.post(
|
||||
"/books", json={"title": "Test Book 2", "description": "Test Description 2"}
|
||||
).status_code == 200
|
||||
assert client.post(
|
||||
"/books", json={"title": "Test Book 3", "description": "Test Description 3"}
|
||||
).status_code == 200
|
||||
|
||||
# Create authors
|
||||
assert client.post("/authors", json={"name": "Test Author 1"}).status_code == 200
|
||||
assert client.post("/authors", json={"name": "Test Author 2"}).status_code == 200
|
||||
assert client.post("/authors", json={"name": "Test Author 3"}).status_code == 200
|
||||
|
||||
# Create genres
|
||||
assert client.post("/genres", json={"name": "Test Genre 1"}).status_code == 200
|
||||
assert client.post("/genres", json={"name": "Test Genre 2"}).status_code == 200
|
||||
assert client.post("/genres", json={"name": "Test Genre 3"}).status_code == 200
|
||||
|
||||
# Create relationships
|
||||
make_authorbook_relationship(1, 1)
|
||||
make_authorbook_relationship(2, 1)
|
||||
make_authorbook_relationship(1, 2)
|
||||
make_authorbook_relationship(2, 3)
|
||||
make_authorbook_relationship(3, 3)
|
||||
make_genrebook_relationship(1, 1)
|
||||
make_genrebook_relationship(2, 1)
|
||||
make_genrebook_relationship(1, 2)
|
||||
make_genrebook_relationship(2, 3)
|
||||
make_genrebook_relationship(3, 3)
|
||||
|
||||
def test_get_book_authors():
|
||||
# Setup test data
|
||||
test_prepare_data()
|
||||
|
||||
response1 = client.get("/books/1/authors")
|
||||
assert response1.status_code == 200, "Invalid response status"
|
||||
assert len(response1.json()) == 2, "Invalid number of authors"
|
||||
@@ -55,7 +89,11 @@ def test_get_book_authors():
|
||||
assert response3.json()[0]["id"] == 2
|
||||
assert response3.json()[1]["id"] == 3
|
||||
|
||||
|
||||
def test_get_author_books():
|
||||
# Setup test data
|
||||
test_prepare_data()
|
||||
|
||||
response1 = client.get("/authors/1/books")
|
||||
assert response1.status_code == 200, "Invalid response status"
|
||||
assert len(response1.json()) == 2, "Invalid number of books"
|
||||
|
||||
Reference in New Issue
Block a user