Initial Commit

This commit is contained in:
2026-02-23 16:42:35 +01:00
commit a5c93d1f8a
12 changed files with 458 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/postgresData/
.env

106
Create.sql Normal file
View File

@ -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.'
);

10
Dockerfile Normal file
View File

@ -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"]

74
README.md Normal file
View File

@ -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
```

6
app/config.py Normal file
View File

@ -0,0 +1,6 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "${DATABASE_URL}"
settings = Settings()

11
app/database.py Normal file
View File

@ -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

14
app/main.py Normal file
View File

@ -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)

0
app/routers/__init__.py Normal file
View File

93
app/routers/authors.py Normal file
View File

@ -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()

99
app/routers/books.py Normal file
View File

@ -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()

37
compose.yaml Normal file
View File

@ -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

6
requirements.txt Normal file
View File

@ -0,0 +1,6 @@
fastapi[all]>=0.113.0
sqlalchemy>=2.0
asyncpg
alembic
pydantic-settings
uvicorn