Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
run: pip install uv
- name: Install tooling
run: pip install uv ruff black
- name: Lint
run: |
ruff check --no-cache
black . --check
- name: Run tests
run: uv run python -m pytest -q
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ tests for every push and pull request. The workflow definition lives in

## API

- `GET /` – returns a simple greeting
- `GET /health` – simple health check
- `POST /pdf/pages` – upload a PDF file and get the page count
- `POST /upload` – upload a non-empty PDF, PNG, or Markdown file
51 changes: 47 additions & 4 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,67 @@
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi_health import health
from pypdf import PdfReader
from pypdf.errors import PdfReadError


ALLOWED_TYPES = {"application/pdf", "image/png", "text/markdown"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB

app = FastAPI()

@app.get("/")
async def read_root():
return {"message": "Hello World"}

def healthy() -> bool:
"""Simple health check condition."""
return True


app.add_api_route("/health", health([healthy]))


@app.post("/pdf/pages")
async def count_pages(file: UploadFile = File(...)):
if file.content_type != "application/pdf":
raise HTTPException(status_code=400, detail="Invalid content type")

# Read file content instead of checking length directly
contents = await file.read()
if len(contents) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="File too large")

try:
reader = PdfReader(file.file)
# Create a PdfReader from the contents
from io import BytesIO

reader = PdfReader(BytesIO(contents))
pages = len(reader.pages)
return {"pages": pages}
except PdfReadError:
raise HTTPException(status_code=400, detail="Invalid PDF file")


@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
"""Upload a file ensuring it's not empty and has an allowed type."""
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=400,
detail="Invalid file type",
)

# Read file content
contents = await file.read()
total_size = len(contents)

if total_size > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="File too large")

if not contents:
raise HTTPException(status_code=400, detail="Empty file")

return {"filename": file.filename, "size": total_size}


if __name__ == "__main__":
import uvicorn

uvicorn.run(app, host="0.0.0.0", port=8000)
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,12 @@ dependencies = [
"httpx>=0.28.1",
"pypdf>=5.6.0",
"python-multipart>=0.0.20",
"fastapi-health>=0.4.0",
]

[dependency-groups]
dev = [
"black>=25.1.0",
"pytest>=8.4.0",
"ruff>=0.12.0",
]
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import sys
from pathlib import Path

# Add the parent directory to sys.path so that 'app' module can be imported
sys.path.insert(0, str(Path(__file__).parent.parent))
63 changes: 59 additions & 4 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,73 @@

client = TestClient(app)

def test_read_root():
response = client.get("/")

def test_health_check():
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}
assert response.json() == {}


def test_count_pages(tmp_path):
pdf_path = tmp_path / "dummy.pdf"
writer = PdfWriter()
writer.add_blank_page(width=72, height=72)

with open(pdf_path, "wb") as f:
writer.write(f)

with open(pdf_path, "rb") as f:
response = client.post("/pdf/pages", files={"file": ("dummy.pdf", f, "application/pdf")})
response = client.post(
"/pdf/pages", files={"file": ("dummy.pdf", f, "application/pdf")}
)

assert response.status_code == 200
assert response.json() == {"pages": 1}


def test_upload_pdf(tmp_path):
"""Uploading a non-empty PDF should succeed."""
pdf_path = tmp_path / "upload.pdf"
writer = PdfWriter()
# writer.add_blank_page(width=72, height=72)
writer.add_blank_page(width=72, height=72)

with open(pdf_path, "wb") as f:
writer.write(f)

with open(pdf_path, "rb") as f:
response = client.post(
"/upload",
files={"file": ("upload.pdf", f, "application/pdf")},
)

assert response.status_code == 200
json_resp = response.json()
assert json_resp["filename"] == "upload.pdf"
assert json_resp["size"] > 0


def test_upload_invalid_type(tmp_path):
txt_path = tmp_path / "bad.txt"
txt_path.write_text("hello")
with open(txt_path, "rb") as f:
response = client.post(
"/upload",
files={"file": ("bad.txt", f, "text/plain")},
)
assert response.status_code == 400
assert response.json()["detail"] == "Invalid file type"


def test_upload_empty_file(tmp_path):
empty_path = tmp_path / "empty.pdf"
empty_path.write_bytes(b"")

with open(empty_path, "rb") as f:
response = client.post(
"/upload",
files={"file": ("empty.pdf", f, "application/pdf")},
)

assert response.status_code == 400
assert response.json()["detail"] == "Empty file"
Loading