commit a5c93d1f8a5f51694dad44c41021465e8c0aedbf Author: Cookiez Date: Mon Feb 23 16:42:35 2026 +0100 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd23f96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/postgresData/ +.env diff --git a/Create.sql b/Create.sql new file mode 100644 index 0000000..b3e9786 --- /dev/null +++ b/Create.sql @@ -0,0 +1,106 @@ +-- ─── TABLES ─────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS authors ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + bio TEXT, + born_date DATE +); + +CREATE TABLE IF NOT EXISTS books ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + author_id INT NOT NULL REFERENCES authors(id) ON DELETE CASCADE, + published DATE, + genre VARCHAR(100), + description TEXT +); + +-- ─── SEED DATA ──────────────────────────────────────────── + +INSERT INTO authors (name, bio, born_date) VALUES +( + 'George Orwell', + 'English novelist and essayist, known for his sharp criticism of totalitarianism.', + '1903-06-25' +), +( + 'J.R.R. Tolkien', + 'English author and philologist, creator of Middle-earth.', + '1892-01-03' +), +( + 'Frank Herbert', + 'American science fiction author best known for the Dune series.', + '1920-10-08' +), +( + 'Ursula K. Le Guin', + 'American author of speculative fiction, known for exploring gender and society.', + '1929-10-21' +); + +INSERT INTO books (title, author_id, published, genre, description) VALUES +( + 'Nineteen Eighty-Four', + 1, + '1949-06-08', + 'Dystopian Fiction', + 'A chilling portrayal of a totalitarian society ruled by Big Brother.' +), +( + 'Animal Farm', + 1, + '1945-08-17', + 'Political Satire', + 'A satirical allegory of the Russian Revolution told through farm animals.' +), +( + 'The Hobbit', + 2, + '1937-09-21', + 'Fantasy', + 'Bilbo Baggins is swept into an epic quest to reclaim the Lonely Mountain.' +), +( + 'The Fellowship of the Ring', + 2, + '1954-07-29', + 'Fantasy', + 'The first part of the Lord of the Rings trilogy, following Frodo and the One Ring.' +), +( + 'The Two Towers', + 2, + '1954-11-11', + 'Fantasy', + 'The fellowship is broken and the war for Middle-earth begins.' +), +( + 'Dune', + 3, + '1965-08-01', + 'Science Fiction', + 'Epic tale of politics, religion and survival on the desert planet Arrakis.' +), +( + 'Dune Messiah', + 3, + '1969-07-01', + 'Science Fiction', + 'Paul Atreides faces the consequences of his rise to power.' +), +( + 'The Left Hand of Darkness', + 4, + '1969-03-01', + 'Science Fiction', + 'An envoy visits a planet where the inhabitants have no fixed gender.' +), +( + 'The Dispossessed', + 4, + '1974-05-01', + 'Science Fiction', + 'A physicist travels between two worlds with opposing political systems.' +); diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da5f0fc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d81ba64 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Project Name + +Short one-line description of what this project does. + +## Tech Stack + +- **[FastAPI](https://fastapi.tiangolo.com/)** — Python REST API framework +- **[PostgreSQL](https://www.postgresql.org/)** — Relational database +- **[SQLAlchemy](https://www.sqlalchemy.org/)** — Async database connection layer +- **[Docker](https://www.docker.com/) / Docker Compose** — Containerized development environment + +## Project Structure +``` +project/ +├──Create.sql — Database schema and seed data +├──docker-compose.yml +├──Dockerfile +├──requirements.txt +├──.env — Local environment variables (not committed) +└──app/ + ├── main.py — App entry point, router registration + ├── config.py — Environment variable configuration + ├── database.py — Database connection and session + └── routers/ + ├── init.py + └── example.py — Route definitions +``` + +## Getting Started + +### Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) installed +- [Docker Compose](https://docs.docker.com/compose/) installed + +### Setup + +Create a .env file in the root directory: + +```text +postgres_user=exampleUser +postgres_password=examplePasswd +postgres_db=exampleDB +db_port=5432 +api_port=8000 +``` + +Start the stack: + +```bash +docker compose up --build +``` + +The API will be available at http://localhost:8000. +Interactive API docs are at http://localhost:8000/docs. +Resetting the Database + +If you need to reinitialize the database (e.g. after changing Create.sql): + +```bash +docker compose down +rm -rf ./postgresData +docker compose up --build +``` + +⚠️ This will delete all data. +Do not run in production. + +```Notes + +The database schema is managed via Create.sql — no ORM migrations + +The .env file is excluded from version control via .gitignore +``` \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6937ed1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,6 @@ +from pydantic_settings import BaseSettings + +class Settings(BaseSettings): + DATABASE_URL: str = "${DATABASE_URL}" + +settings = Settings() diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..b24cc41 --- /dev/null +++ b/app/database.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy import text +from app.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=True) +AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..7786b8b --- /dev/null +++ b/app/main.py @@ -0,0 +1,14 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI +from app.database import engine +from app.routers import authors, books + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield # tables already created by Create.sql + await engine.dispose() + +app = FastAPI(title="Database API", lifespan=lifespan) + +app.include_router(authors.router) +app.include_router(books.router) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routers/authors.py b/app/routers/authors.py new file mode 100644 index 0000000..d1f7e37 --- /dev/null +++ b/app/routers/authors.py @@ -0,0 +1,93 @@ +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from app.database import get_db + +router = APIRouter(prefix="/authors", tags=["Authors"]) + + +@router.get("/") +async def get_all_authors(db: AsyncSession = Depends(get_db)): + result = await db.execute(text("SELECT * FROM authors ORDER BY name")) + return result.mappings().all() + + +@router.get("/{author_id}") +async def get_author(author_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute( + text("SELECT * FROM authors WHERE id = :id"), + {"id": author_id} + ) + author = result.mappings().one_or_none() + if not author: + raise HTTPException(status_code=404, detail="Author not found") + return author + + +@router.get("/{author_id}/books") +async def get_books_by_author(author_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute( + text(""" + SELECT b.id, b.title, b.published, b.genre + FROM books b + WHERE b.author_id = :author_id + ORDER BY b.published DESC + """), + {"author_id": author_id} + ) + return result.mappings().all() + + +@router.post("/", status_code=201) +async def create_author( + name: str, bio: str = None, born_date: date = None, + db: AsyncSession = Depends(get_db) +): + await db.execute( + text(""" + INSERT INTO authors (name, bio, born_date) + VALUES (:name, :bio, :born_date) + """), + {"name": name, "bio": bio, "born_date": born_date} + ) + await db.commit() + return {"message": f"Author '{name}' created"} + + +@router.put("/{author_id}") +async def update_author( + author_id: int, name: str = None, bio: str = None, + db: AsyncSession = Depends(get_db) +): + result = await db.execute( + text("SELECT id FROM authors WHERE id = :id"), {"id": author_id} + ) + if not result.one_or_none(): + raise HTTPException(status_code=404, detail="Author not found") + + await db.execute( + text(""" + UPDATE authors + SET name = COALESCE(:name, name), + bio = COALESCE(:bio, bio) + WHERE id = :id + """), + {"id": author_id, "name": name, "bio": bio} + ) + await db.commit() + return {"message": "Author updated"} + + +@router.delete("/{author_id}", status_code=204) +async def delete_author(author_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute( + text("SELECT id FROM authors WHERE id = :id"), {"id": author_id} + ) + if not result.one_or_none(): + raise HTTPException(status_code=404, detail="Author not found") + + await db.execute( + text("DELETE FROM authors WHERE id = :id"), {"id": author_id} + ) + await db.commit() diff --git a/app/routers/books.py b/app/routers/books.py new file mode 100644 index 0000000..42b0061 --- /dev/null +++ b/app/routers/books.py @@ -0,0 +1,99 @@ +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from app.database import get_db + +router = APIRouter(prefix="/books", tags=["Books"]) + + +@router.get("/") +async def get_all_books(db: AsyncSession = Depends(get_db)): + result = await db.execute(text(""" + SELECT b.id, b.title, b.genre, b.published, a.name AS author + FROM books b + JOIN authors a ON a.id = b.author_id + ORDER BY b.title + """)) + return result.mappings().all() + + +@router.get("/{book_id}") +async def get_book(book_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute( + text(""" + SELECT b.*, a.name AS author_name + FROM books b + JOIN authors a ON a.id = b.author_id + WHERE b.id = :id + """), + {"id": book_id} + ) + book = result.mappings().one_or_none() + if not book: + raise HTTPException(status_code=404, detail="Book not found") + return book + + +@router.post("/", status_code=201) +async def create_book( + title: str, author_id: int, genre: str = None, + published: date = None, description: str = None, + db: AsyncSession = Depends(get_db) +): + # Check author exists + result = await db.execute( + text("SELECT id FROM authors WHERE id = :id"), {"id": author_id} + ) + if not result.one_or_none(): + raise HTTPException(status_code=404, detail="Author not found") + + await db.execute( + text(""" + INSERT INTO books (title, author_id, genre, published, description) + VALUES (:title, :author_id, :genre, :published, :description) + """), + {"title": title, "author_id": author_id, "genre": genre, + "published": published, "description": description} + ) + await db.commit() + return {"message": f"Book '{title}' created"} + + +@router.put("/{book_id}") +async def update_book( + book_id: int, title: str = None, genre: str = None, + description: str = None, db: AsyncSession = Depends(get_db) +): + result = await db.execute( + text("SELECT id FROM books WHERE id = :id"), {"id": book_id} + ) + if not result.one_or_none(): + raise HTTPException(status_code=404, detail="Book not found") + + await db.execute( + text(""" + UPDATE books + SET title = COALESCE(:title, title), + genre = COALESCE(:genre, genre), + description = COALESCE(:description, description) + WHERE id = :id + """), + {"id": book_id, "title": title, "genre": genre, "description": description} + ) + await db.commit() + return {"message": "Book updated"} + + +@router.delete("/{book_id}", status_code=204) +async def delete_book(book_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute( + text("SELECT id FROM books WHERE id = :id"), {"id": book_id} + ) + if not result.one_or_none(): + raise HTTPException(status_code=404, detail="Book not found") + + await db.execute( + text("DELETE FROM books WHERE id = :id"), {"id": book_id} + ) + await db.commit() diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fe133d2 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,37 @@ +services: + api: + restart: unless-stopped + container_name: api_${postgres_db} + build: . + env_file: + - .env + ports: + - "${api_port}:8000" + environment: + DATABASE_URL: postgresql+asyncpg://${postgres_user}:${postgres_password}@db:${db_port}/${postgres_db} + depends_on: + db: + condition: service_healthy + volumes: + - ./app:/app/app + + db: + image: postgres:18-alpine + container_name: postgres_${postgres_db} + restart: unless-stopped + shm_size: 128mb + ports: + - ${db_port}:5432 + volumes: + - ./postgresData:/var/lib/postgresql/data + - ./Create.sql:/docker-entrypoint-initdb.d/Create.sql + environment: + POSTGRES_USER: ${postgres_user} + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_DB: ${postgres_db} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${postgres_user} -d ${postgres_db}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f073499 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +fastapi[all]>=0.113.0 +sqlalchemy>=2.0 +asyncpg +alembic +pydantic-settings +uvicorn