Initial commit.

This commit is contained in:
g_it 2026-03-31 11:43:01 +02:00
commit 2fa935016e
Signed by untrusted user who does not match committer: g_it
GPG key ID: A2B0A7C06A054627
11 changed files with 1410 additions and 0 deletions

0
app/__init__.py Normal file
View file

17
app/database.py Normal file
View file

@ -0,0 +1,17 @@
import sqlite3
from pathlib import Path
DATABASE_PATH = Path(__file__).parent.parent / "data" / "reviews.db"
def get_db():
"""
FastAPI dependency that provides a database connection.
Yields a connection, then closes it when the request is done.
"""
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()

76
app/main.py Normal file
View file

@ -0,0 +1,76 @@
from fastapi import FastAPI
from scalar_fastapi import get_scalar_api_reference
from app.routes import discovery, reviews, stats
DESCRIPTION = """
Hi, this is a read-only API exposing 20+ years of my personal media reviews of movies, books,
and television since 2001.
## What this API does
This API provides structured access to a personal media review database. Every
review includes a date, category, title, creator, genre, rating, and optional
written commentary.
## Rating scale
Ratings use a **-3.0 to 3.0** scale:
| Rating | Meaning |
|--------|---------|
| 3.0 | Masterpiece, tears flowed |
| 2.0 | Great, I was glowing for a day |
| 1.0 | Good enough use of my time |
| 0.0 | Meh, a void of space |
| -1.0 | Below average, should have spent this time knitting |
| -2.0 | Bad, I want someone to pay me for this |
| -3.0 | Terrible, I felt like I needed to use toilet paper after |
## Getting started
- Browse available categories, genres, and creators via the **Discovery**
endpoints
- Fetch reviews with filtering and pagination via **GET /reviews**
- Explore aggregate statistics via the **Statistics** endpoints
- Get a surprise recommendation via **GET /reviews/random**
## About
This API is a portfolio project demonstrating API design and documentation
skills. Built with [FastAPI](https://fastapi.tiangolo.com/) and documented
with [Scalar](https://scalar.com/).
"""
CUSTOM_CSS="""
.darklight-reference-promo { display: none !important; }
"""
app = FastAPI(
title="Reviews API",
description=DESCRIPTION,
version="1.0.0",
docs_url=None,
redoc_url=None,
)
@app.get("/docs", include_in_schema=False)
async def scalar_docs():
return get_scalar_api_reference(
openapi_url=app.openapi_url,
title=app.title,
force_dark_mode_state="light",
hide_dark_mode_toggle=True,
default_open_all_tags=False,
custom_css=CUSTOM_CSS,
)
app.include_router(reviews.router)
app.include_router(discovery.router)
app.include_router(stats.router)
@app.get("/health", tags=["System"])
def health_check():
"""Verify the API is running."""
return {"status": "ok"}

175
app/models.py Normal file
View file

@ -0,0 +1,175 @@
from pydantic import BaseModel, Field
from typing import Optional
# ──────────────────────────────────────────────
# Core review models
# ──────────────────────────────────────────────
class Review(BaseModel):
"""A single review of a movie, book, or television show."""
date: str = Field(
...,
description="Date the review was recorded, in YYYY-MM-DD format.",
examples=["2025-02-06"],
)
unixTime: int = Field(
...,
description="Unix timestamp corresponding to the review date.",
examples=[1738796400],
)
category: Optional[str] = Field(
None,
description="Media type: Movies, Books, or Television.",
examples=["Movies"],
)
title: Optional[str] = Field(
None,
description="Title of the work being reviewed.",
examples=["The Brutalist"],
)
creator: Optional[str] = Field(
None,
description="Director, author, or showrunner of the work.",
examples=["Brady Corbet"],
)
genre: Optional[str] = Field(
None,
description="Genre classification of the work.",
examples=["Drama"],
)
rating: Optional[float] = Field(
None,
description="Rating on a scale from -3.0 (worst) to 3.0 (best). A rating of 0.0 indicates a neutral opinion (it was meh).",
examples=[3.0],
)
review: Optional[str] = Field(
None,
description="Free-text review. Empty string or '.' indicates no written review.",
examples=[
"A wonderfully expansive film about the immigrant experience."
],
)
year: Optional[int] = Field(
None,
description="Release or publication year of the work.",
examples=[2024],
)
model_config = {"from_attributes": True}
class ReviewListResponse(BaseModel):
"""Paginated response wrapper for a list of reviews."""
total: int = Field(
...,
description="Total number of reviews matching the query filters.",
examples=[642],
)
limit: int = Field(
...,
description="Maximum number of reviews returned in this response.",
examples=[20],
)
offset: int = Field(
...,
description="Number of reviews skipped from the start of the result set.",
examples=[0],
)
results: list[Review] = Field(
...,
description="The reviews for the current page.",
)
# ──────────────────────────────────────────────
# Discovery models
# ──────────────────────────────────────────────
class CategoryCount(BaseModel):
"""A category with its review count."""
category: str = Field(..., description="The media category name.", examples=["Movies"])
count: int = Field(..., description="Number of reviews in this category.", examples=[412])
class GenreCount(BaseModel):
"""A genre with its review count."""
genre: str = Field(..., description="The genre name.", examples=["Science Fiction"])
count: int = Field(..., description="Number of reviews in this genre.", examples=[58])
class CreatorCount(BaseModel):
"""A creator with their review count."""
creator: str = Field(..., description="Name of the director, author, or showrunner.", examples=["Steven Spielberg"])
count: int = Field(..., description="Number of works by this creator that have been reviewed.", examples=[7])
# ──────────────────────────────────────────────
# Stats models
# ──────────────────────────────────────────────
class CategoryBreakdown(BaseModel):
"""Review count for a single category."""
category: str = Field(..., examples=["Movies"])
count: int = Field(..., examples=[412])
class OverviewStats(BaseModel):
"""High-level statistics across all reviews."""
total_reviews: int = Field(..., description="Total number of reviews in the database.", examples=[642])
categories: list[CategoryBreakdown] = Field(..., description="Number of reviews per category.")
average_rating: float = Field(..., description="Mean rating across all reviews.", examples=[1.02])
earliest_review: str = Field(..., description="Date of the oldest review.", examples=["2001-06-29"])
latest_review: str = Field(..., description="Date of the most recent review.", examples=["2025-02-09"])
class CreatorStats(BaseModel):
"""A creator ranked by number of reviewed works."""
creator: str = Field(..., description="Name of the creator.", examples=["Steven Spielberg"])
review_count: int = Field(..., description="Number of reviewed works by this creator.", examples=[7])
average_rating: float = Field(..., description="Mean rating across this creator's reviewed works.", examples=[1.57])
class GenreStats(BaseModel):
"""A genre with aggregated review statistics."""
genre: str = Field(..., description="The genre name.", examples=["Science Fiction"])
review_count: int = Field(..., description="Number of reviews in this genre.", examples=[58])
average_rating: float = Field(..., description="Mean rating for reviews in this genre.", examples=[1.24])
class YearActivity(BaseModel):
"""Review activity for a single year."""
year: str = Field(..., description="The four-digit year.", examples=["2025"])
review_count: int = Field(..., description="Number of reviews recorded in this year.", examples=[3])
average_rating: float = Field(..., description="Mean rating for reviews in this year.", examples=[1.67])
class DecadeStats(BaseModel):
"""Review activity grouped by the decade the work was released."""
decade: str = Field(
...,
description="The decade label (e.g. '1990s').",
examples=["1990s"],
)
review_count: int = Field(
...,
description="Number of reviewed works released in this decade.",
examples=[45],
)
average_rating: float = Field(
...,
description="Mean rating for works released in this decade.",
examples=[1.35],
)

0
app/routes/__init__.py Normal file
View file

76
app/routes/discovery.py Normal file
View file

@ -0,0 +1,76 @@
import sqlite3
from fastapi import APIRouter, Depends, Query
from app.database import get_db
from app.models import CategoryCount, CreatorCount, GenreCount
router = APIRouter(tags=["Discovery"])
@router.get(
"/categories",
response_model=list[CategoryCount],
summary="List all categories",
description="Returns every media category, along with a count of the number of reviews in that category."
)
def list_categories(db: sqlite3.Connection = Depends(get_db)):
rows = db.execute(
"SELECT category, COUNT(*) as count FROM reviews GROUP BY category ORDER BY count DESC"
).fetchall()
return [dict(row) for row in rows]
@router.get(
"/genres",
response_model=list[GenreCount],
summary="List all genres",
description="Returns every genre with review counts. Optionally filter to genres within a specific category.",
)
def list_genres(
category: str | None = Query(None, description="Only show genres in this category.", examples=["Movies"]),
db: sqlite3.Connection = Depends(get_db),
):
conditions = []
params = []
if category:
conditions.append("category = ?")
params.append(category)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
rows = db.execute(
f"SELECT genre, COUNT(*) as count FROM reviews {where} GROUP BY genre ORDER BY count DESC",
params,
).fetchall()
return [dict(row) for row in rows]
@router.get(
"/creators",
response_model=list[CreatorCount],
summary="List all creators",
description="Returns every writer, director, or creator; with the number of works by them that has been reviewed. "
"Optionally filter by category.",
)
def list_creators(
category: str | None = Query(None, description="Only show creators in this category.", examples=["Books"]),
db: sqlite3.Connection = Depends(get_db),
):
conditions = []
params = []
if category:
conditions.append("category = ?")
params.append(category)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
rows = db.execute(
f"SELECT creator, COUNT(*) as count FROM reviews {where} GROUP BY creator ORDER BY count DESC",
params,
).fetchall()
return [dict(row) for row in rows]

234
app/routes/reviews.py Normal file
View file

@ -0,0 +1,234 @@
import sqlite3
from fastapi import APIRouter, Depends, HTTPException, Query
from app.database import get_db
from app.models import Review, ReviewListResponse
router = APIRouter(prefix="/reviews", tags=["Reviews"])
@router.get(
"",
response_model=ReviewListResponse,
summary="List reviews",
description="Retrieve a paginated list of reviews with optional filters. All filters are combined with AND logic, where only reviews matching every specified filter are returned.",
)
def list_reviews(
category: str | None = Query(
None, description="Filter by media category (exact match).", examples=["Movies"]
),
creator: str | None = Query(
None, description="Filter by creator name (exact match).", examples=["Christopher Nolan"]
),
genre: str | None = Query(
None, description="Filter by genre (exact match).", examples=["Science Fiction"]
),
min_rating: float | None = Query(
None, description="Minimum rating (inclusive).", ge=-3.0, le=3.0, examples=[2.0]
),
max_rating: float | None = Query(
None, description="Maximum rating (inclusive).", ge=-3.0, le=3.0, examples=[3.0]
),
date_from: str | None = Query(
None, description="Earliest review date (inclusive, YYYY-MM-DD).", examples=["2020-01-01"]
),
date_to: str | None = Query(
None, description="Latest review date (inclusive, YYYY-MM-DD).", examples=["2025-12-31"]
),
year_from: int | None = Query(
None, description="Earliest release year (inclusive).", examples=[1990]
),
year_to: int | None = Query(
None, description="Latest release year (inclusive).", examples=[2000]
),
limit: int = Query(
20, description="Number of results per page.", ge=1, le=100
),
offset: int = Query(
0, description="Number of results to skip.", ge=0
),
db: sqlite3.Connection = Depends(get_db),
):
conditions = []
params = []
if category:
conditions.append("category = ?")
params.append(category)
if creator:
conditions.append("creator = ?")
params.append(creator)
if genre:
conditions.append("genre = ?")
params.append(genre)
if min_rating is not None:
conditions.append("rating >= ?")
params.append(min_rating)
if max_rating is not None:
conditions.append("rating <= ?")
params.append(max_rating)
if date_from:
conditions.append("date >= ?")
params.append(date_from)
if date_to:
conditions.append("date <= ?")
params.append(date_to)
if year_from is not None:
conditions.append("year >= ?")
params.append(year_from)
if year_to is not None:
conditions.append("year <= ?")
params.append(year_to)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
total = db.execute(
f"SELECT COUNT(*) FROM reviews {where}", params
).fetchone()[0]
rows = db.execute(
f"SELECT * FROM reviews {where} ORDER BY date DESC LIMIT ? OFFSET ?",
params + [limit, offset],
).fetchall()
return ReviewListResponse(
total=total,
limit=limit,
offset=offset,
results=[dict(row) for row in rows],
)
@router.get(
"/random",
response_model=Review,
summary="Get a random review",
description="Returns a single randomly selected review. Optionally filter by category to get a random review from a specific media type.",
)
def random_review(
category: str | None = Query(
None, description="Limit random selection to this category.", examples=["Books"]
),
db: sqlite3.Connection = Depends(get_db),
):
conditions = []
params = []
if category:
conditions.append("category = ?")
params.append(category)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
row = db.execute(
f"SELECT * FROM reviews {where} ORDER BY RANDOM() LIMIT 1", params
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="No reviews found.")
return dict(row)
@router.get(
"/top",
response_model=list[Review],
summary="Get top-rated reviews",
description="Returns the highest-rated reviews, sorted by rating descending.",
)
def top_reviews(
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
year_from: int | None = Query(None, description="Earliest release year (inclusive).", examples=[1990]),
year_to: int | None = Query(None, description="Latest release year (inclusive).", examples=[2000]),
limit: int = Query(10, description="Number of results to return.", ge=1, le=50),
db: sqlite3.Connection = Depends(get_db),
):
conditions = []
params = []
if category:
conditions.append("category = ?")
params.append(category)
if year_from is not None:
conditions.append("year >= ?")
params.append(year_from)
if year_to is not None:
conditions.append("year <= ?")
params.append(year_to)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
rows = db.execute(
f"SELECT * FROM reviews {where} ORDER BY rating DESC LIMIT ?",
params + [limit],
).fetchall()
return [dict(row) for row in rows]
@router.get(
"/bottom",
response_model=list[Review],
summary="Get lowest-rated reviews",
description="Returns the lowest-rated reviews, sorted by rating ascending. "
"These are the harshest takes in the collection.",
)
def bottom_reviews(
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
year_from: int | None = Query(None, description="Earliest release year (inclusive).", examples=[1990]),
year_to: int | None = Query(None, description="Latest release year (inclusive).", examples=[2000]),
limit: int = Query(10, description="Number of results to return.", ge=1, le=50),
db: sqlite3.Connection = Depends(get_db),
):
conditions = []
params = []
if category:
conditions.append("category = ?")
params.append(category)
if year_from is not None:
conditions.append("year >= ?")
params.append(year_from)
if year_to is not None:
conditions.append("year <= ?")
params.append(year_to)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
rows = db.execute(
f"SELECT * FROM reviews {where} ORDER BY rating ASC LIMIT ?",
params + [limit],
).fetchall()
return [dict(row) for row in rows]
@router.get(
"/{category}/{title}/{creator}",
response_model=Review,
summary="Get a specific review",
description="Retrieve a single review by its unique combination of "
"category, title, and creator.",
)
def get_review(
category: str,
title: str,
creator: str,
db: sqlite3.Connection = Depends(get_db),
):
row = db.execute(
"SELECT * FROM reviews WHERE category = ? AND title = ? AND creator = ?",
[category, title, creator],
).fetchone()
if not row:
raise HTTPException(
status_code=404,
detail=f"No review found for '{title}' by {creator} in {category}.",
)
return dict(row)

152
app/routes/stats.py Normal file
View file

@ -0,0 +1,152 @@
import sqlite3
from fastapi import APIRouter, Depends, Query
from app.database import get_db
from app.models import CreatorStats, DecadeStats, GenreStats, OverviewStats, YearActivity
router = APIRouter(prefix="/stats", tags=["Statistics"])
@router.get(
"",
response_model=OverviewStats,
summary="Overall statistics",
description="High-level numbers: total reviews, breakdown by category, average rating, and date range of the reviews collection.",
)
def overview(db: sqlite3.Connection = Depends(get_db)):
total = db.execute("SELECT COUNT(*) FROM reviews").fetchone()[0]
categories = db.execute(
"SELECT category, COUNT(*) as count FROM reviews GROUP BY category ORDER BY count DESC"
).fetchall()
avg = db.execute("SELECT ROUND(AVG(rating), 2) FROM reviews").fetchone()[0]
earliest = db.execute("SELECT MIN(date) FROM reviews").fetchone()[0]
latest = db.execute("SELECT MAX(date) FROM reviews").fetchone()[0]
return OverviewStats(
total_reviews=total,
categories=[dict(row) for row in categories],
average_rating=avg,
earliest_review=earliest,
latest_review=latest,
)
@router.get(
"/creators",
response_model=list[CreatorStats],
summary="Creator rankings",
description="Writers, directors and creators ranked by number of reviewed works, with their average rating.",
)
def creator_stats(
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
limit: int = Query(20, description="Number of creators to return.", ge=1, le=100),
db: sqlite3.Connection = Depends(get_db),
):
conditions = []
params = []
if category:
conditions.append("category = ?")
params.append(category)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
rows = db.execute(
f"SELECT creator, COUNT(*) as review_count, "
f"ROUND(AVG(rating), 2) as average_rating "
f"FROM reviews {where} "
f"GROUP BY creator ORDER BY review_count DESC LIMIT ?",
params + [limit],
).fetchall()
return [dict(row) for row in rows]
@router.get(
"/genres",
response_model=list[GenreStats],
summary="Genre breakdown",
description="Genres with review counts and average ratings. Optionally filtered by media category.",
)
def genre_stats(
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
db: sqlite3.Connection = Depends(get_db),
):
conditions = []
params = []
if category:
conditions.append("category = ?")
params.append(category)
where = ""
if conditions:
where = "WHERE " + " AND ".join(conditions)
rows = db.execute(
f"SELECT genre, COUNT(*) as review_count, "
f"ROUND(AVG(rating), 2) as average_rating "
f"FROM reviews {where} "
f"GROUP BY genre ORDER BY review_count DESC",
params,
).fetchall()
return [dict(row) for row in rows]
@router.get(
"/timeline",
response_model=list[YearActivity],
summary="Review timeline",
description="Reviews per year, showing how my reviewing activity changed over time.",
)
def timeline(db: sqlite3.Connection = Depends(get_db)):
rows = db.execute(
"SELECT SUBSTR(date, 1, 4) as year, COUNT(*) as review_count, "
"ROUND(AVG(rating), 2) as average_rating "
"FROM reviews GROUP BY year ORDER BY year ASC"
).fetchall()
return [dict(row) for row in rows]
@router.get(
"/decades",
response_model=list[DecadeStats],
summary="Reviews by decade of release",
description="Reviews grouped by the decade the work was released. Shows which eras of media appear most in the reviews collection.",
)
def decade_stats(
category: str | None = Query(None, description="Limit to this category.", examples=["Movies"]),
db: sqlite3.Connection = Depends(get_db),
):
conditions = ["year IS NOT NULL"]
params = []
if category:
conditions.append("category = ?")
params.append(category)
where = "WHERE " + " AND ".join(conditions)
rows = db.execute(
f"SELECT (year / 10 * 10) as decade_num, COUNT(*) as review_count, "
f"ROUND(AVG(rating), 2) as average_rating "
f"FROM reviews {where} "
f"GROUP BY decade_num ORDER BY decade_num ASC",
params,
).fetchall()
return [
DecadeStats(
decade=f"{row['decade_num']}s",
review_count=row["review_count"],
average_rating=row["average_rating"],
)
for row in rows
]