diff --git a/.env b/.env new file mode 100644 index 0000000..610aa73 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +POSTGRES_USER = "postgres" +POSTGRES_PASSWORD = "password" +POSTGRES_DB = "mydatabase" +POSTGRES_SERVER = "db" diff --git a/.env.example b/.env.example deleted file mode 100644 index 787729d..0000000 --- a/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -POSTGRES_USER=postgres -POSTGRES_PASSWORD=password -POSTGRES_DB=mydatabase diff --git a/.gitignore b/.gitignore index 11a3b84..0db9584 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,141 @@ -!.env.example -.env - +# Byte-compiled / optimized / DLL files __pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.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 + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.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 + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# 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 +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Jetbrains' IDEs +.idea + +# VS code +.vscode + +# JUPITER +*.ipynb + +# Postgres data +data/ diff --git a/Dockerfile b/Dockerfile index ab9e788..e8d8370 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,22 @@ -FROM python:3.11 +FROM python:3.13 as requirements-stage +WORKDIR /tmp +RUN pip install poetry +RUN poetry self add poetry-plugin-export +COPY ./pyproject.toml ./poetry.lock* /tmp/ +RUN poetry export -f requirements.txt --output requirements.txt --with dev --without-hashes + +FROM python:3.13 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get -y install gcc postgresql \ + && apt-get clean # netcat + +RUN pip install --upgrade pip WORKDIR /code - -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -COPY ./src/alembic.ini ./ -COPY ./src/app /code/app -COPY ./tests /code/tests - -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +COPY --from=requirements-stage /tmp/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir --upgrade -r ./requirements.txt +COPY . . +ENV PYTHONPATH=. diff --git a/Makefile b/Makefile deleted file mode 100644 index 7939961..0000000 --- a/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -.PHONY: help build up start down destroy stop restart logs db-shell - -help: - @echo "Available commands:" - @echo " make build - Build the Docker images" - @echo " make up - Start the containers" - @echo " make down - Stop and remove the containers" - @echo " make db-shell - Access the database shell" - -build: - docker-compose -f docker-compose.yml build - -up: - docker-compose -f docker-compose.yml up -d - -down: - docker-compose -f docker-compose.yml down - -db-shell: - docker-compose -f docker-compose.yml exec timescale psql -Upostgres diff --git a/README.md b/README.md index c2bc0fd..45a1bcd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# Book API +# LibraryAPI + +## WARNING: The documentation is now out of date. 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. @@ -18,7 +20,7 @@ For development: 1. Clone the repository: ```bash - git clone https://github.com/wowlikon/bookapi.git + git clone https://github.com/wowlikon/libraryapi.git ``` 2. Navigate to the project directory: diff --git a/alembic.ini b/alembic.ini old mode 100644 new mode 100755 index e63faa7..4da05b0 --- a/alembic.ini +++ b/alembic.ini @@ -2,23 +2,22 @@ [alembic] # path to migration scripts -script_location = app/migrations +script_location = migrations # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s # Uncomment the line below if you want the files to be prepended with date and time -# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file -# for all available tokens # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s # sys.path path, will be prepended to sys.path if present. # defaults to the current working directory. prepend_sys_path = . +path_separator = os # timezone to use when rendering the date within the migration file # as well as the filename. -# If specified, requires the python>=3.9 or backports.zoneinfo library. -# Any required deps can installed by adding `alembic[tz]` to the pip requirements -# string value is passed to ZoneInfo() +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() # leave blank for localtime # timezone = @@ -51,16 +50,11 @@ prepend_sys_path = . # version_path_separator = space version_path_separator = os # Use os.pathsep. Default configuration used for new projects. -# set to 'true' to search source files recursively -# in each "version_locations" directory -# new in Alembic version 1.10 -# recursive_version_locations = false - # the output encoding used when revision files # are written from script.py.mako # output_encoding = utf-8 -sqlalchemy.url = driver://user:pass@localhost/dbname +sqlalchemy.url = postgresql+asyncpg://user:pwd@db:5432/foo [post_write_hooks] @@ -74,12 +68,6 @@ sqlalchemy.url = driver://user:pass@localhost/dbname # black.entrypoint = black # black.options = -l 79 REVISION_SCRIPT_FILENAME -# lint with attempts to fix using "ruff" - use the exec runner, execute a binary -# hooks = ruff -# ruff.type = exec -# ruff.executable = %(here)s/.venv/bin/ruff -# ruff.options = --fix REVISION_SCRIPT_FILENAME - # Logging configuration [loggers] keys = root,sqlalchemy,alembic diff --git a/app/database.py b/app/database.py deleted file mode 100644 index f6e7d41..0000000 --- a/app/database.py +++ /dev/null @@ -1,14 +0,0 @@ -from sqlmodel import create_engine, SQLModel, Session -from decouple import config - -# Get database configuration -DATABASE_URL = config('DATABASE_URL', cast=str, default='sqlite:///./bookapi.db') - -# Create database engine -engine = create_engine(str(DATABASE_URL), echo=True) -# SQLModel.metadata.create_all(engine) - -# Get database session -def get_session(): - with Session(engine) as session: - yield session diff --git a/app/main.py b/app/main.py deleted file mode 100644 index a072847..0000000 --- a/app/main.py +++ /dev/null @@ -1,220 +0,0 @@ -from datetime import datetime -from pathlib import Path -from typing import Dict, List - -from alembic import command -from alembic.config import Config -from fastapi import FastAPI, Depends, Request, HTTPException -from fastapi.responses import HTMLResponse, JSONResponse -from fastapi.templating import Jinja2Templates -from sqlmodel import SQLModel, Session, select - -from .database import engine, get_session -from .models import Author, AuthorBase, Book, BookBase, AuthorBookLink - -alembic_cfg = Config("alembic.ini") -templates = Jinja2Templates(directory=Path(__file__).parent / "templates") -app = FastAPI( - title="LibraryAPI", - description="This is a sample API for managing authors and books.", - version="1.0.1", - openapi_tags=[ - { - "name": "authors", - "description": "Operations with authors.", - }, - { - "name": "books", - "description": "Operations with books.", - }, - { - "name": "relations", - "description": "Operations with relations.", - }, - { - "name": "misc", - "description": "Miscellaneous operations.", - } - ] -) - -def get_info() -> Dict: - return { - "status": "ok", - "app_info": { - "title": app.title, - "version": app.version, - "description": app.description, - }, - "server_time": datetime.now().isoformat(), - } - -# Initialize the database -@app.on_event("startup") -def on_startup(): - # Apply database migrations - with engine.begin() as connection: - alembic_cfg.attributes['connection'] = connection - command.upgrade(alembic_cfg, "head") - -# Root endpoint -@app.get("/", response_class=HTMLResponse) -async def root(request: Request): - return templates.TemplateResponse("index.html", {"request": request, "data": get_info()}) - -# API Information endpoint -@app.get("/api/info", tags=["misc"]) -async def api_info(): - return JSONResponse(content=get_info()) - -# Create an author -@app.post("/authors/", response_model=Author, tags=["authors"]) -def create_author(author: AuthorBase, session: Session = Depends(get_session)): - db_author = Author(name=author.name) - session.add(db_author) - session.commit() - session.refresh(db_author) - return db_author - -# Read authors -@app.get("/authors/", response_model=List[Author], tags=["authors"]) -def read_authors(session: Session = Depends(get_session)): - authors = session.exec(select(Author)).all() - return authors - -# Update an author -@app.put("/authors/{author_id}", response_model=Author, tags=["authors"]) -def update_author(author_id: int, author: AuthorBase, session: Session = Depends(get_session)): - db_author = session.get(Author, author_id) - if not db_author: - raise HTTPException(status_code=404, detail="Author not found") - db_author.name = author.name - session.commit() - session.refresh(db_author) - return db_author - -# Delete an author -@app.delete("/authors/{author_id}", response_model=AuthorBase, tags=["authors"]) -def delete_author(author_id: int, session: Session = Depends(get_session)): - db_author = session.get(Author, author_id) - if not db_author: - raise HTTPException(status_code=404, detail="Author not found") - session.delete(db_author) - author = AuthorBase(name=db_author.name) - session.commit() - return author - -# Create a book with authors -@app.post("/books/", response_model=Book, tags=["books"]) -def create_book(book: BookBase, session: Session = Depends(get_session)): - db_book = Book(title=book.title, description=book.description) - session.add(db_book) - session.commit() - session.refresh(db_book) - return db_book - -# Read books -@app.get("/books/", response_model=List[Book], tags=["books"]) -def read_books(session: Session = Depends(get_session)): - books = session.exec(select(Book)).all() - return books - -# Update a book with authors -@app.put("/books/{book_id}", response_model=Book, tags=["books"]) -def update_book(book_id: int, book: BookBase, 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") - - db_book.title = book.title - db_book.description = book.description - session.commit() - session.refresh(db_book) - return db_book - -# Delete a book -@app.delete("/books/{book_id}", response_model=BookBase, tags=["books"]) -def delete_book(book_id: int, 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") - book = Book(title=db_book.title, description=db_book.description) - session.delete(db_book) - session.commit() - return book - -# Add author to book -@app.post("/relationships/", response_model=AuthorBookLink, tags=["relations"]) -def add_author_to_book(author_id: int, book_id: int, session: Session = Depends(get_session)): - # Check if author and book exist - author = session.get(Author, author_id) - if not author: - raise HTTPException(status_code=404, detail="Author not found") - - book = session.get(Book, book_id) - if not book: - raise HTTPException(status_code=404, detail="Book not found") - - # Check if relationship already exists - existing_link = session.exec( - select(AuthorBookLink) - .where(AuthorBookLink.author_id == author_id) - .where(AuthorBookLink.book_id == book_id) - ).first() - - if existing_link: - raise HTTPException(status_code=400, detail="Relationship already exists") - - # Create new relationship - link = AuthorBookLink(author_id=author_id, book_id=book_id) - session.add(link) - session.commit() - session.refresh(link) - return link - -# Remove author from book -@app.delete("/relationships/", tags=["relations"]) -def remove_author_from_book(author_id: int, book_id: int, session: Session = Depends(get_session)): - # Find the relationship - link = session.exec( - select(AuthorBookLink) - .where(AuthorBookLink.author_id == author_id) - .where(AuthorBookLink.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 all authors for a book -@app.get("/books/{book_id}/authors/", response_model=List[Author], tags=["books", "relations"]) -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) - ).all() - - return authors - -# Get all books for an author -@app.get("/authors/{author_id}/books/", response_model=List[Book], tags=["authors", "relations"]) -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) - ).all() - - return books diff --git a/app/migrations/README b/app/migrations/README deleted file mode 100644 index 98e4f9c..0000000 --- a/app/migrations/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/app/migrations/versions/__pycache__/d266fdc61e99_init.cpython-311.pyc b/app/migrations/versions/__pycache__/d266fdc61e99_init.cpython-311.pyc deleted file mode 100644 index c54e06a..0000000 Binary files a/app/migrations/versions/__pycache__/d266fdc61e99_init.cpython-311.pyc and /dev/null differ diff --git a/app/models.py b/app/models.py deleted file mode 100644 index 82500e9..0000000 --- a/app/models.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import List -from sqlmodel import SQLModel, Field, Relationship - -# Relationship model -class AuthorBookLink(SQLModel, table=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) - -# Author DTO model -class AuthorBase(SQLModel): - name: str - - class Config: # pyright: ignore - json_schema_extra = { - "example": { - "name": "author_name", - } - } - -# Author DB model -class Author(AuthorBase, table=True): - id: int | None = Field(default=None, primary_key=True, index=True) - books: List["Book"] = Relationship(back_populates="authors", link_model=AuthorBookLink) - -# Book DTO model -class BookBase(SQLModel): - title: str - description: str - - class Config: # pyright: ignore - json_schema_extra = { - "example": { - "title": "book_title", - "description": "book_description", - } - } - -# Book DB model -class Book(BookBase, table=True): - id: int | None = Field(default=None, primary_key=True, index=True) - authors: List[Author] = Relationship(back_populates="books", link_model=AuthorBookLink) diff --git a/docker-compose.yml b/docker-compose.yml index 1e8dd84..bcbcf2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,33 +1,30 @@ services: db: - image: postgres:15-alpine - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ports: - - "5432:5432" + container_name: db_library + image: postgres + expose: + - 5432 volumes: - - postgres_data:/var/lib/postgresql/data + - ./data/db:/var/lib/postgresql/data + env_file: + - ./.env api: + container_name: api_library build: . - environment: - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + command: bash -c "alembic upgrade head && uvicorn library_service.main:app --reload --host 0.0.0.0 --port 8000" + volumes: + - .:/code ports: - "8000:8000" - volumes: - - ./src/app/migrations/versions:/code/app/migrations/versions depends_on: - db - test: + tests: + container_name: tests build: . - command: pytest - environment: - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + command: bash -c "pytest tests/test_misc.py" + volumes: + - .:/code depends_on: - db - -volumes: - postgres_data: diff --git a/app/__init__.py b/library_service/__init__.py similarity index 100% rename from app/__init__.py rename to library_service/__init__.py diff --git a/library_service/api.py b/library_service/api.py new file mode 100644 index 0000000..7d3b3c8 --- /dev/null +++ b/library_service/api.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter +import asyncpg + +router = APIRouter( + prefix='/devices' +) diff --git a/library_service/main.py b/library_service/main.py new file mode 100644 index 0000000..6593445 --- /dev/null +++ b/library_service/main.py @@ -0,0 +1,28 @@ +from alembic import command +from alembic.config import Config +from contextlib import asynccontextmanager +from fastapi import FastAPI +from toml import load + +from .settings import engine, get_app +from .routers import api_router +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 + command.upgrade(alembic_cfg, "head") + + print("[+] Starting...") + yield # Here FastAPI will start handling requests; + print("[+] Application shutdown") + +# Include routers +app.include_router(api_router) diff --git a/library_service/models/__init__.py b/library_service/models/__init__.py new file mode 100644 index 0000000..4794be4 --- /dev/null +++ b/library_service/models/__init__.py @@ -0,0 +1,2 @@ +from .dto import * +from .db import * diff --git a/library_service/models/db/__init__.py b/library_service/models/db/__init__.py new file mode 100644 index 0000000..e47964f --- /dev/null +++ b/library_service/models/db/__init__.py @@ -0,0 +1,7 @@ +from .author import Author +from .book import Book +from .links import AuthorBookLink, AuthorWithBooks, BookWithAuthors + +__all__ = [ + 'Author', 'Book', 'AuthorBookLink', 'AuthorWithBooks', 'BookWithAuthors' +] diff --git a/library_service/models/db/author.py b/library_service/models/db/author.py new file mode 100644 index 0000000..6c54b0c --- /dev/null +++ b/library_service/models/db/author.py @@ -0,0 +1,14 @@ +from typing import List, Optional, TYPE_CHECKING +from sqlmodel import SQLModel, Field, Relationship +from ..dto.author import AuthorBase +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 + ) diff --git a/library_service/models/db/book.py b/library_service/models/db/book.py new file mode 100644 index 0000000..54f85e2 --- /dev/null +++ b/library_service/models/db/book.py @@ -0,0 +1,14 @@ +from typing import List, Optional, TYPE_CHECKING +from sqlmodel import SQLModel, Field, Relationship +from ..dto.book import BookBase +from .links import AuthorBookLink + +if TYPE_CHECKING: + from .author import Author + +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 + ) diff --git a/library_service/models/db/links.py b/library_service/models/db/links.py new file mode 100644 index 0000000..cb68a02 --- /dev/null +++ b/library_service/models/db/links.py @@ -0,0 +1,15 @@ +from sqlmodel import SQLModel, Field +from typing import List + +from library_service.models.dto.author import AuthorRead +from library_service.models.dto.book import BookRead + +class AuthorBookLink(SQLModel, table=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 AuthorWithBooks(AuthorRead): + books: List[BookRead] = Field(default_factory=list) + +class BookWithAuthors(BookRead): + authors: List[AuthorRead] = Field(default_factory=list) diff --git a/library_service/models/dto/__init__.py b/library_service/models/dto/__init__.py new file mode 100644 index 0000000..4f16be6 --- /dev/null +++ b/library_service/models/dto/__init__.py @@ -0,0 +1,15 @@ +from .author import ( + AuthorBase, AuthorCreate, AuthorUpdate, + AuthorRead, AuthorList +) +from .book import ( + BookBase, BookCreate, BookUpdate, + BookRead, BookList +) +# from .common import PaginatedResponse + +__all__ = [ + 'AuthorBase', 'AuthorCreate', 'AuthorUpdate', 'AuthorRead', 'AuthorList', + 'BookBase', 'BookCreate', 'BookUpdate', 'BookRead', 'BookList', + # 'PaginatedResponse' +] diff --git a/library_service/models/dto/author.py b/library_service/models/dto/author.py new file mode 100644 index 0000000..a3b2e32 --- /dev/null +++ b/library_service/models/dto/author.py @@ -0,0 +1,25 @@ +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"} + } + ) + +class AuthorCreate(AuthorBase): + pass + +class AuthorUpdate(SQLModel): + name: Optional[str] = None + +class AuthorRead(AuthorBase): + id: int + +class AuthorList(SQLModel): + authors: List[AuthorRead] + total: int diff --git a/library_service/models/dto/book.py b/library_service/models/dto/book.py new file mode 100644 index 0000000..4ea9eef --- /dev/null +++ b/library_service/models/dto/book.py @@ -0,0 +1,30 @@ +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 + json_schema_extra={ + "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 diff --git a/src/app/routers/__init__.py b/library_service/routers/__init__.py similarity index 85% rename from src/app/routers/__init__.py rename to library_service/routers/__init__.py index f2847b1..e9df8ef 100644 --- a/src/app/routers/__init__.py +++ b/library_service/routers/__init__.py @@ -7,7 +7,8 @@ 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(relationships_router) -api_router.include_router(misc_router) \ No newline at end of file +api_router.include_router(misc_router) diff --git a/library_service/routers/authors.py b/library_service/routers/authors.py new file mode 100644 index 0000000..c47a033 --- /dev/null +++ b/library_service/routers/authors.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, 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 +) + +router = APIRouter(prefix="/authors", tags=["authors"]) + +# Create an author +@router.post("/", response_model=AuthorRead) +def create_author(author: AuthorCreate, session: Session = Depends(get_session)): + db_author = Author(**author.model_dump()) + session.add(db_author) + session.commit() + session.refresh(db_author) + return AuthorRead(**db_author.model_dump()) + +# Read authors +@router.get("/", response_model=AuthorList) +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) + ) + +# Read an author with their books +@router.get("/{author_id}", response_model=AuthorWithBooks) +def get_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) + ).all() + + book_reads = [BookRead(**book.model_dump()) for book in books] + + author_data = author.model_dump() + author_data['books'] = book_reads + + return AuthorWithBooks(**author_data) + +# Update an author +@router.put("/{author_id}", response_model=AuthorRead) +def update_author( + author_id: int, + author: AuthorUpdate, + session: Session = Depends(get_session) +): + db_author = session.get(Author, author_id) + if not db_author: + raise HTTPException(status_code=404, detail="Author not found") + + update_data = author.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_author, field, value) + + session.commit() + 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)): + author = session.get(Author, author_id) + if not author: + raise HTTPException(status_code=404, detail="Author not found") + + author_read = AuthorRead(**author.model_dump()) + session.delete(author) + session.commit() + return author_read diff --git a/library_service/routers/books.py b/library_service/routers/books.py new file mode 100644 index 0000000..2c77e48 --- /dev/null +++ b/library_service/routers/books.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, select +from typing import Deque, List + +from library_service.settings import get_session +from library_service.models.db import Book, Author, AuthorBookLink, BookWithAuthors +from library_service.models.dto import ( + BookCreate, BookUpdate, BookRead, + BookList, AuthorRead +) + +router = APIRouter(prefix="/books", tags=["books"]) + +# Create a book +@router.post("/", response_model=Book) +def create_book(book: BookCreate, session: Session = Depends(get_session)): + db_book = Book(**book.model_dump()) + session.add(db_book) + session.commit() + session.refresh(db_book) + return BookRead(**db_book.model_dump()) + +# Read books +@router.get("/", response_model=BookList) +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) + ) + +# Read a book +@router.get("/{book_id}", response_model=BookWithAuthors) +def get_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") + return BookWithAuthors(**book.model_dump()) + +# Update a book +@router.put("/{book_id}", response_model=Book) +def update_book(book_id: int, book: BookUpdate, 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") + + db_book.title = book.title or db_book.title + db_book.description = book.description or db_book.description + session.commit() + 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)): + 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) + session.delete(book) + session.commit() + return book_read + +# Get all authors for a book +@router.get("/{book_id}/authors/", response_model=List[AuthorRead]) +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) + ).all() + + return [AuthorRead(**author.model_dump()) for author in authors] diff --git a/src/app/routers/misc.py b/library_service/routers/misc.py similarity index 61% rename from src/app/routers/misc.py rename to library_service/routers/misc.py index c0665f3..a014d9d 100644 --- a/src/app/routers/misc.py +++ b/library_service/routers/misc.py @@ -1,15 +1,21 @@ -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, FastAPI +from fastapi.params import Depends from fastapi.responses import HTMLResponse, JSONResponse 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", @@ -23,10 +29,10 @@ def get_info(app) -> Dict: # Root endpoint @router.get("/", response_class=HTMLResponse) -async def root(request: Request, app=None): - return templates.TemplateResponse("index.html", {"request": request, "data": get_info(app)}) +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") -async def api_info(app=None): - return JSONResponse(content=get_info(app)) \ No newline at end of file +async def api_info(app=Depends(get_app)): + return JSONResponse(content=get_info(app)) diff --git a/src/app/routers/relationships.py b/library_service/routers/relationships.py similarity index 84% rename from src/app/routers/relationships.py rename to library_service/routers/relationships.py index 42c3091..9aa45b4 100644 --- a/src/app/routers/relationships.py +++ b/library_service/routers/relationships.py @@ -2,15 +2,14 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select from typing import List, Dict -from ..database import get_session -from ..models import Author, Book, AuthorBookLink +from library_service.settings import get_session +from library_service.models.db import Book, Author, AuthorBookLink router = APIRouter(prefix="/relationships", tags=["relations"]) # Add author to book @router.post("/", response_model=AuthorBookLink) def add_author_to_book(author_id: int, book_id: int, session: Session = Depends(get_session)): - # Check if author and book exist author = session.get(Author, author_id) if not author: raise HTTPException(status_code=404, detail="Author not found") @@ -19,7 +18,6 @@ def add_author_to_book(author_id: int, book_id: int, session: Session = Depends( if not book: raise HTTPException(status_code=404, detail="Book not found") - # Check if relationship already exists existing_link = session.exec( select(AuthorBookLink) .where(AuthorBookLink.author_id == author_id) @@ -29,7 +27,6 @@ def add_author_to_book(author_id: int, book_id: int, session: Session = Depends( if existing_link: raise HTTPException(status_code=400, detail="Relationship already exists") - # Create new relationship link = AuthorBookLink(author_id=author_id, book_id=book_id) session.add(link) session.commit() @@ -39,7 +36,6 @@ def add_author_to_book(author_id: int, book_id: int, session: Session = Depends( # Remove author from book @router.delete("/", response_model=Dict[str, str]) def remove_author_from_book(author_id: int, book_id: int, session: Session = Depends(get_session)): - # Find the relationship link = session.exec( select(AuthorBookLink) .where(AuthorBookLink.author_id == author_id) @@ -51,4 +47,4 @@ def remove_author_from_book(author_id: int, book_id: int, session: Session = Dep session.delete(link) session.commit() - return {"message": "Relationship removed successfully"} \ No newline at end of file + return {"message": "Relationship removed successfully"} diff --git a/library_service/settings.py b/library_service/settings.py new file mode 100644 index 0000000..5177490 --- /dev/null +++ b/library_service/settings.py @@ -0,0 +1,52 @@ +import os +from dotenv import load_dotenv +from fastapi import FastAPI +from sqlmodel import create_engine, SQLModel, Session +from toml import load + +load_dotenv() + +with open("pyproject.toml") as f: + config = load(f) + +# Dependency to get the FastAPI application instance +def get_app() -> FastAPI: + return FastAPI( + title=config["tool"]["poetry"]["name"], + description=config["tool"]["poetry"]["description"], + version=config["tool"]["poetry"]["version"], + openapi_tags=[ + { + "name": "authors", + "description": "Operations with authors.", + }, + { + "name": "books", + "description": "Operations with books.", + }, + { + "name": "relations", + "description": "Operations with relations.", + }, + { + "name": "misc", + "description": "Miscellaneous operations.", + } + ] + ) + +USER = os.getenv("POSTGRES_USER") +PASSWORD = os.getenv("POSTGRES_PASSWORD") +DATABASE = os.getenv("POSTGRES_DB") +HOST = os.getenv("POSTGRES_SERVER") + +if not USER or not PASSWORD or not DATABASE or not HOST: + raise ValueError("Missing environment variables") + +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: + yield session diff --git a/app/templates/index.html b/library_service/templates/index.html similarity index 82% rename from app/templates/index.html rename to library_service/templates/index.html index c9b6997..4c37657 100644 --- a/app/templates/index.html +++ b/library_service/templates/index.html @@ -3,7 +3,7 @@ - {{ data.title }} + {{ app_info.title }} -

Welcome to {{ data.title }}!

-

Description: {{ data.description }}

-

Version: {{ data.version }}

-

Current Time: {{ data.time }}

-

Status: {{ data.status }}

+

Welcome to {{ app_info.title }}!

+

Description: {{ app_info.description }}

+

Version: {{ app_info.version }}

+

Current Time: {{ server_time }}

+

Status: {{ status }}