Initial Commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/postgresData/
|
||||||
|
.env
|
||||||
106
Create.sql
Normal file
106
Create.sql
Normal 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
10
Dockerfile
Normal 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
74
README.md
Normal 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
6
app/config.py
Normal 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
11
app/database.py
Normal 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
14
app/main.py
Normal 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
0
app/routers/__init__.py
Normal file
93
app/routers/authors.py
Normal file
93
app/routers/authors.py
Normal 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
99
app/routers/books.py
Normal 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
37
compose.yaml
Normal 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
6
requirements.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
fastapi[all]>=0.113.0
|
||||||
|
sqlalchemy>=2.0
|
||||||
|
asyncpg
|
||||||
|
alembic
|
||||||
|
pydantic-settings
|
||||||
|
uvicorn
|
||||||
Reference in New Issue
Block a user