Skip to content

Commit

Permalink
Merge pull request #120 from voynow/119-begin-migration-away-from-lambda
Browse files Browse the repository at this point in the history
119 begin migration away from lambda
  • Loading branch information
voynow authored Nov 10, 2024
2 parents 9258fe8 + 64bd177 commit 08b3e96
Show file tree
Hide file tree
Showing 58 changed files with 2,616 additions and 198 deletions.
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,11 @@ Packages/

tests/artifacts
.vercel

# Terraform
*.tfstate
*.tfstate.*
.terraform/
.terraform.lock.hcl
*.tfvars
*.tfvars.json
33 changes: 33 additions & 0 deletions api/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/bash

# Check if Docker is running, and start it if not
if ! docker info >/dev/null 2>&1; then
echo "Starting Docker..."
open --background -a Docker
while ! docker info >/dev/null 2>&1; do
sleep 1
done
echo "Docker started."
fi

# Build and push the image
poetry export --without-hashes -f requirements.txt -o requirements.txt
docker build -t trackflow .
docker tag trackflow:latest 498969721544.dkr.ecr.us-east-1.amazonaws.com/trackflow:latest
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 498969721544.dkr.ecr.us-east-1.amazonaws.com
docker push 498969721544.dkr.ecr.us-east-1.amazonaws.com/trackflow:latest

# Get the image digest
image_digest=$(aws ecr describe-images \
--repository-name trackflow \
--image-ids imageTag=latest \
--query 'imageDetails[0].imageDigest' \
--output text)

# Update the image in the Terraform variables
cd ../infra/app
sed -i '' "s|^image = .*|image = \"498969721544.dkr.ecr.us-east-1.amazonaws.com/trackflow@$image_digest\"|" terraform.tfvars
terraform apply -auto-approve

# Return to the api folder
cd ../../api
12 changes: 12 additions & 0 deletions api/dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM --platform=linux/amd64 python:3.11-slim

WORKDIR /app

COPY . /app

RUN pip install --no-cache-dir -r requirements.txt

EXPOSE 80

# Start the app with Python
CMD ["python", "-m", "uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "80"]
1,557 changes: 1,557 additions & 0 deletions api/poetry.lock

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[tool.poetry]
name = "trackflow-api"
version = "0.1.0"
description = ""
authors = ["voynow <voynow99@gmail.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.115.4"
uvicorn = "^0.32.0"
supabase = "^2.10.0"
python-dotenv = "^1.0.1"
pyjwt = "^2.9.0"
stravalib = "^2.1"

[tool.poetry.group.dev.dependencies]
pytest = "^8.3.3"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
52 changes: 52 additions & 0 deletions api/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
aiohappyeyeballs==2.4.3 ; python_version >= "3.11" and python_version < "4.0"
aiohttp==3.10.10 ; python_version >= "3.11" and python_version < "4.0"
aiosignal==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
annotated-types==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
anyio==4.6.2.post1 ; python_version >= "3.11" and python_version < "4.0"
arrow==1.3.0 ; python_version >= "3.11" and python_version < "4.0"
attrs==24.2.0 ; python_version >= "3.11" and python_version < "4.0"
certifi==2024.8.30 ; python_version >= "3.11" and python_version < "4.0"
charset-normalizer==3.4.0 ; python_version >= "3.11" and python_version < "4.0"
click==8.1.7 ; python_version >= "3.11" and python_version < "4.0"
colorama==0.4.6 ; python_version >= "3.11" and python_version < "4.0" and platform_system == "Windows"
deprecation==2.1.0 ; python_version >= "3.11" and python_version < "4.0"
fastapi==0.115.4 ; python_version >= "3.11" and python_version < "4.0"
flexcache==0.3 ; python_version >= "3.11" and python_version < "4.0"
flexparser==0.4 ; python_version >= "3.11" and python_version < "4.0"
frozenlist==1.5.0 ; python_version >= "3.11" and python_version < "4.0"
gotrue==2.10.0 ; python_version >= "3.11" and python_version < "4.0"
h11==0.14.0 ; python_version >= "3.11" and python_version < "4.0"
h2==4.1.0 ; python_version >= "3.11" and python_version < "4.0"
hpack==4.0.0 ; python_version >= "3.11" and python_version < "4.0"
httpcore==1.0.6 ; python_version >= "3.11" and python_version < "4.0"
httpx==0.27.2 ; python_version >= "3.11" and python_version < "4.0"
httpx[http2]==0.27.2 ; python_version >= "3.11" and python_version < "4.0"
hyperframe==6.0.1 ; python_version >= "3.11" and python_version < "4.0"
idna==3.10 ; python_version >= "3.11" and python_version < "4.0"
multidict==6.1.0 ; python_version >= "3.11" and python_version < "4.0"
packaging==24.2 ; python_version >= "3.11" and python_version < "4.0"
pint==0.24.4 ; python_version >= "3.11" and python_version < "4.0"
platformdirs==4.3.6 ; python_version >= "3.11" and python_version < "4.0"
postgrest==0.18.0 ; python_version >= "3.11" and python_version < "4.0"
propcache==0.2.0 ; python_version >= "3.11" and python_version < "4.0"
pydantic-core==2.23.4 ; python_version >= "3.11" and python_version < "4.0"
pydantic==2.9.2 ; python_version >= "3.11" and python_version < "4.0"
pyjwt==2.9.0 ; python_version >= "3.11" and python_version < "4.0"
python-dateutil==2.9.0.post0 ; python_version >= "3.11" and python_version < "4.0"
python-dotenv==1.0.1 ; python_version >= "3.11" and python_version < "4.0"
pytz==2024.2 ; python_version >= "3.11" and python_version < "4.0"
realtime==2.0.6 ; python_version >= "3.11" and python_version < "4.0"
requests==2.32.3 ; python_version >= "3.11" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.11" and python_version < "4.0"
sniffio==1.3.1 ; python_version >= "3.11" and python_version < "4.0"
starlette==0.41.2 ; python_version >= "3.11" and python_version < "4.0"
storage3==0.9.0 ; python_version >= "3.11" and python_version < "4.0"
stravalib==2.1 ; python_version >= "3.11" and python_version < "4.0"
supabase==2.10.0 ; python_version >= "3.11" and python_version < "4.0"
supafunc==0.7.0 ; python_version >= "3.11" and python_version < "4.0"
types-python-dateutil==2.9.0.20241003 ; python_version >= "3.11" and python_version < "4.0"
typing-extensions==4.12.2 ; python_version >= "3.11" and python_version < "4.0"
urllib3==2.2.3 ; python_version >= "3.11" and python_version < "4.0"
uvicorn==0.32.0 ; python_version >= "3.11" and python_version < "4.0"
websockets==13.1 ; python_version >= "3.11" and python_version < "4.0"
yarl==1.17.1 ; python_version >= "3.11" and python_version < "4.0"
135 changes: 135 additions & 0 deletions api/src/auth_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import logging
import os
from typing import Optional

import jwt
from fastapi import HTTPException, Security
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from src import supabase_client
from src.types.user import UserAuthRow, UserRow
from stravalib.client import Client

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
bearer_scheme = HTTPBearer()

strava_client = Client()


def generate_jwt(athlete_id: int, expires_at: int) -> str:
"""
Generate a JWT token using athlete_id and expiration time, aligning token
expiration cycle with the athlete's Strava token
:param athlete_id: strava internal identifier
:param expires_at: expiration time of strava token
:return: str
"""
payload = {"athlete_id": athlete_id, "exp": expires_at}
token = jwt.encode(payload, os.environ["JWT_SECRET"], algorithm="HS256")
return token


def decode_jwt(jwt_token: str, verify_exp: bool = True) -> int:
"""
Decode JWT token and return athlete_id
:param jwt_token: JWT token
:param verify_exp: whether to verify expiration
:return: int if successful, None if decoding fails
:raises: jwt.DecodeError if token is invalid
"""
payload = jwt.decode(
jwt_token,
os.environ["JWT_SECRET"],
algorithms=["HS256"],
options={"verify_exp": verify_exp},
)
return payload["athlete_id"]


def refresh_and_update_user_token(athlete_id: int, refresh_token: str) -> UserAuthRow:
"""
Refresh the user's Strava token and update database
:param athlete_id: strava internal identifier
:param refresh_token: refresh token for Strava API
:return: UserAuthRow
"""
logger.info(f"Refreshing and updating token for athlete {athlete_id}")
access_info = strava_client.refresh_access_token(
client_id=os.environ["STRAVA_CLIENT_ID"],
client_secret=os.environ["STRAVA_CLIENT_SECRET"],
refresh_token=refresh_token,
)
new_jwt_token = generate_jwt(
athlete_id=athlete_id, expires_at=access_info["expires_at"]
)

user_auth = UserAuthRow(
athlete_id=athlete_id,
access_token=access_info["access_token"],
refresh_token=access_info["refresh_token"],
expires_at=access_info["expires_at"],
jwt_token=new_jwt_token,
device_token=supabase_client.get_device_token(athlete_id),
)
supabase_client.upsert_user_auth(user_auth)
return user_auth


def validate_and_refresh_token(token: str) -> int:
"""
Validate and refresh the user's credentials in DB
:param token: JWT token
:return: athlete_id
"""
try:
athlete_id = decode_jwt(token)
except jwt.ExpiredSignatureError:
try:
# If the token is expired, decode athlete_id and refresh
athlete_id = decode_jwt(token, verify_exp=False)
user_auth = supabase_client.get_user_auth(athlete_id)
refresh_and_update_user_token(
athlete_id=athlete_id, refresh_token=user_auth.refresh_token
)
except jwt.DecodeError:
logger.error("Invalid JWT token")
raise HTTPException(status_code=401, detail="Invalid JWT token")
except Exception as e:
logger.error(
f"Unknown error validating and refreshing token: {e}",
exc_info=True,
)
raise HTTPException(status_code=500, detail="Internal server error")
except jwt.DecodeError:
logger.error("Invalid JWT token")
raise HTTPException(status_code=401, detail="Invalid JWT token")
except Exception as e:
logger.error(
f"Unknown error validating and refreshing token: {e}",
exc_info=True,
)
raise HTTPException(status_code=500, detail="Internal server error")

return athlete_id


async def validate_user(
credentials: HTTPAuthorizationCredentials = Security(bearer_scheme),
) -> UserRow:
"""
Dependency that validates the JWT token from the Authorization header
:param credentials: Bearer token credentials
:return: UserRow
"""
athlete_id = validate_and_refresh_token(credentials.credentials)
if athlete_id is None:
logger.error("Invalid authentication credentials")
raise HTTPException(
status_code=401, detail="Invalid authentication credentials"
)
return supabase_client.get_user(athlete_id)
29 changes: 29 additions & 0 deletions api/src/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import logging

from fastapi import Depends, FastAPI, HTTPException
from src import supabase_client
from src.auth_manager import validate_user
from src.types.training_week import TrainingWeek
from src.types.user import UserRow

app = FastAPI()

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


@app.get("/training_week/", response_model=TrainingWeek)
async def training_week(user: UserRow = Depends(validate_user)):
"""
Retrieve the most recent training_week row by athlete_id
curl -X GET "http://trackflow-alb-499532887.us-east-1.elb.amazonaws.com/training_week/" \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
:param athlete_id: The athlete_id to retrieve the training_week for
:return: The most recent training_week row for the athlete
"""
try:
return supabase_client.get_training_week(user.athlete_id)
except ValueError as e:
logger.error(f"Error retrieving training week: {e}", exc_info=True)
raise HTTPException(status_code=404, detail=str(e))
Loading

0 comments on commit 08b3e96

Please sign in to comment.