Skip to content
Open
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
39 changes: 39 additions & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python application

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

permissions:
contents: read

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- name: Set up Python 3.10
uses: actions/setup-python@v3
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pytest
63 changes: 33 additions & 30 deletions app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,45 +18,48 @@


async def _get_jwks() -> dict:
global _jwks_cache, _jwks_fetched_at
now = time.time()
if _jwks_cache and (now - _jwks_fetched_at) < JWKS_TTL:
return _jwks_cache
async with httpx.AsyncClient() as client:
resp = await client.get(settings.clerk_jwks_url, timeout=10)
resp.raise_for_status()
_jwks_cache = resp.json()
"""Fetch and cache JWKS from Clerk."""
global _jwks_cache, _jwks_fetched_at
now = time.time()
if _jwks_cache and (now - _jwks_fetched_at) < JWKS_TTL:
return _jwks_cache

async with httpx.AsyncClient() as client:
resp = await client.get(settings.clerk_jwks_url, timeout=10)
resp.raise_for_status()
_jwks_cache = resp.json()

_jwks_fetched_at = now
return _jwks_cache


async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> dict:
"""Verifies Clerk JWT and returns decoded payload with user info."""
"""Verifies Clerk JWT and returns decoded payload with user info."""
token = credentials.credentials
jwks = await _get_jwks()

try:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
key = next(
(k for k in jwks.get("keys", []) if k.get("kid") == kid), None
)
if key is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No matching JWK found for token kid",
)

public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
payload = jwt.decode(
token, public_key, algorithms=["RS256"],
options={"verify_aud": False},
)
return payload

except jwt.ExpiredSignatureError:
header = jwt.get_unverified_header(token)
kid = header.get("kid")
key = next(
(k for k in jwks.get("keys", []) if k.get("kid") == kid), None
)
if key is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No matching JWK found for token kid",
)

public_key = jwt.algorithms.RSAAlgorithm.from_jwk(key)
payload = jwt.decode(
token, public_key, algorithms=["RS256"],
options={"verify_aud": False},
)
return payload

except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
except jwt.InvalidTokenError as exc:
except jwt.InvalidTokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token: {exc}")
42 changes: 24 additions & 18 deletions app/routers/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,50 +10,56 @@


class ItemCreate(BaseModel):
name: str
description: str = ""
name: str
description: str = ""


@router.get("/items")
async def list_items(user: dict = Depends(get_current_user)):
"""List all items for the authenticated user (cached 5 min)."""
user_id = user["sub"]
cache_key = f"items:{user_id}"
cached = await cache_get(cache_key)
if cached is not None:
return {"items": cached, "cached": True}
db = get_supabase()
"""List all items for the authenticated user (cached 5 min)."""
user_id = user["sub"]
cache_key = f"items:{user_id}"
cached = await cache_get(cache_key)
if cached is not None:
return {"items": cached, "cached": True}

db = get_supabase()
result = db.table("items").select("*").eq("user_id", user_id).execute()
await cache_set(cache_key, result.data, ttl_seconds=300)
return {"items": result.data, "cached": False}


@router.post("/items", status_code=201)
async def create_item(item: ItemCreate, user: dict = Depends(get_current_user)):
"""Create a new item for the authenticated user."""
"""Create a new item for the authenticated user."""
user_id = user["sub"]
db = get_supabase()
result = db.table("items").insert({
"name": item.name,
"description": item.description,
"user_id": user_id,
"name": item.name,
"description": item.description,
"user_id": user_id,
}).execute()

if not result.data:
raise HTTPException(status_code=500, detail="Failed to create item")
from app.cache import get_redis
raise HTTPException(status_code=500, detail="Failed to create item")

from app.cache import get_redis
r = get_redis()
await r.delete(f"items:{user_id}")

return result.data[0]


@router.delete("/items/{item_id}", status_code=204)
async def delete_item(item_id: str, user: dict = Depends(get_current_user)):
"""Delete an item (only if it belongs to the authenticated user)."""
"""Delete an item (only if it belongs to the authenticated user)."""
user_id = user["sub"]
db = get_supabase()
result = db.table("items").delete().eq("id", item_id).eq("user_id", user_id).execute()

if not result.data:
raise HTTPException(status_code=404, detail="Item not found")
from app.cache import get_redis
raise HTTPException(status_code=404, detail="Item not found")

from app.cache import get_redis
r = get_redis()
await r.delete(f"items:{user_id}")