Skip to content

Commit d23b607

Browse files
authored
Merge pull request #1 from blacksmithop/redis-cache-dockerize
2 parents 3e73a7e + d3d3dec commit d23b607

File tree

20 files changed

+488
-770
lines changed

20 files changed

+488
-770
lines changed

Makefile

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
.PHONY: build clean run
1+
.PHONY: build clean run down logs
22

3-
# Build Docker images in parallel
43
build:
5-
docker build -f api.Dockerfile -t api-image .
6-
docker build -f frontend.Dockerfile -t frontend-image .
7-
8-
# Clean up orphan containers
4+
docker-compose build --parallel
5+
96
clean:
10-
docker rm -f $$(docker ps -q -f "status=exited") 2>/dev/null || true
7+
docker-compose down --remove-orphans
8+
docker system prune -f
119

12-
# Run the containers
1310
run: clean
14-
docker run -d -p 8000:8000 api-image
15-
docker run -d -p 3000:3000 frontend-image
11+
docker-compose up -d
12+
docker-compose logs -f
13+
14+
down:
15+
docker-compose down
16+
17+
# View logs
18+
logs:
19+
docker-compose logs -f

backend/.dockerignore

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Python-specific ignores
2+
__pycache__/
3+
*.pyc
4+
*.pyo
5+
*.pyd
6+
.Python
7+
env/
8+
venv/
9+
.venv/
10+
.mypy_cache/
11+
.pytest_cache/
12+
.ipynb_checkpoints/
13+
14+
# Editor/IDE-specific ignores
15+
.vscode/
16+
.idea/
17+
*.swp
18+
*.bak
19+
*~
20+
21+
# Version control ignores
22+
.git
23+
.gitignore
24+
25+
# Build/distribution ignores
26+
dist/
27+
build/
28+
*.egg-info/
29+
30+
# Log files
31+
*.log

backend/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ COPY app/ ./app/
2121
# Expose port
2222
EXPOSE 8122
2323

24-
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8122"]
24+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8122", "--reload"]

backend/app/config/api.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,27 @@ class EndpointConfig:
88
Attributes:
99
path (str): The endpoint path (e.g., 'user/attacks').
1010
access_level (str): Required access level (e.g., 'Public', 'Minimal', 'Limited').
11+
ttl_seconds (int): Time to live in seconds for caching.
1112
"""
1213
path: str
1314
access_level: str
15+
ttl_seconds: int
16+
17+
def __str__(self) -> str:
18+
"""Return the path string when the object is converted to string.
19+
20+
Returns:
21+
str: The endpoint path.
22+
"""
23+
return self.path
24+
25+
def __int__(self) -> int:
26+
"""Return the TTL seconds when the object is converted to int.
27+
28+
Returns:
29+
int: The TTL in seconds.
30+
"""
31+
return self.ttl_seconds
1432

1533

1634
class TornApiConfig:
@@ -33,31 +51,36 @@ class TornApiConfig:
3351

3452
BASIC_PROFILE_ENDPOINT: EndpointConfig = EndpointConfig(
3553
path="user/basic",
36-
access_level="Public"
54+
access_level="Public",
55+
ttl_seconds=3600 # 1 hour
3756
)
3857
"""Endpoint for fetching basic user profile (requires Public access)."""
3958

4059
BARS_ENDPOINT: EndpointConfig = EndpointConfig(
4160
path="user/bars",
42-
access_level="Minimal"
61+
access_level="Minimal",
62+
ttl_seconds=3600 # 1 hour
4363
)
4464
"""Endpoint for fetching user bars information (requires Minimal access)."""
4565

4666
REVIVES_ENDPOINT: EndpointConfig = EndpointConfig(
4767
path="user/revives",
48-
access_level="Minimal"
68+
access_level="Minimal",
69+
ttl_seconds=86400 # 24 hours
4970
)
5071
"""Endpoint for fetching user revives (requires Minimal access)."""
5172

5273
REVIVES_FULL_ENDPOINT: EndpointConfig = EndpointConfig(
5374
path="user/revivesfull",
54-
access_level="Minimal"
75+
access_level="Minimal",
76+
ttl_seconds=86400 # 24 hours
5577
)
5678
"""Endpoint for fetching user revives (full) (requires Minimal access)."""
5779

5880
REVIVES_STATISTICS_ENDPOINT: EndpointConfig = EndpointConfig(
5981
path="user/personalstats",
60-
access_level="Public"
82+
access_level="Public",
83+
ttl_seconds=3600 # 1 hour
6184
)
6285
"""Endpoint for fetching user revive statistics (requires Public access)."""
6386

@@ -90,7 +113,7 @@ def __repr__(self) -> str:
90113
str: Detailed string with base URL and endpoint details.
91114
"""
92115
endpoints = [
93-
f"{attr}: {getattr(self, attr).path} ({getattr(self, attr).access_level})"
116+
f"{attr}: {getattr(self, attr)} ({getattr(self, attr).access_level}, TTL: {int(getattr(self, attr))}s)"
94117
for attr in dir(self)
95118
if isinstance(getattr(self, attr), EndpointConfig)
96119
]
@@ -105,12 +128,12 @@ def get_endpoint_url(self, endpoint: EndpointConfig) -> str:
105128
Returns:
106129
str: The full URL combining the base URL and endpoint path.
107130
"""
108-
return f"{self.BASE_URL}/{endpoint.path}"
131+
return f"{self.BASE_URL}/{str(endpoint)}"
109132

110133
def get_auth_header(self, api_key) -> dict[str, str]:
111134
"""Get the Authorization header with the API key.
112135
113136
Returns:
114137
dict[str, str]: Header dictionary with 'Authorization' key.
115138
"""
116-
return {"Authorization": f"ApiKey {api_key}"}
139+
return {"Authorization": f"ApiKey {api_key}"}

backend/app/core/api.py

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,34 @@
11
from fastapi import Header, HTTPException
22
import hishel
3+
import httpx
34
from app.config import EndpointConfig
45
from app.config import TornApiConfig
56
from pydantic import BaseModel
7+
from .config import settings
8+
from redis import Redis
9+
610

711
class ApiErrorResponse(BaseModel):
812
error: dict[str, object]
913

1014

1115
api_config = TornApiConfig()
16+
# Create base storage without TTL (will be set per request)
17+
base_storage = hishel.RedisStorage(
18+
client=Redis(host=settings.REDIS_HOST, port=settings.REDIS_PORT)
19+
)
1220

1321
BASE_HEADERS = {"User-Agent": "TornApiClient/1.0"}
1422

1523

16-
async def fetch_torn_api(api_key: str ,endpoint: EndpointConfig, params: dict) -> dict:
24+
async def fetch_torn_api(api_key: str, endpoint: EndpointConfig, params: dict, cache: bool = True, ttl: int = 300) -> dict:
1725
"""Helper function to make requests to the Torn API with caching.
1826
1927
Args:
2028
endpoint (EndpointConfig): The endpoint configuration from TornApiConfig.
2129
params (dict): Query parameters to include in the request.
30+
cache (bool): Whether to cache or not. Defaults to True.
31+
ttl (int): How long to keep cache in seconds. Defaults to 300 (5 minutes).
2232
2333
Returns:
2434
dict: The JSON response from the Torn API.
@@ -27,38 +37,81 @@ async def fetch_torn_api(api_key: str ,endpoint: EndpointConfig, params: dict) -
2737
HTTPException: If the request fails or the API returns an error.
2838
"""
2939
headers = {**BASE_HEADERS, **api_config.get_auth_header(api_key=api_key)}
30-
31-
# Configure Hishel's AsyncCacheClient with in-memory storage
32-
async with hishel.AsyncCacheClient(
33-
) as client:
34-
try:
35-
response = await client.get(
36-
api_config.get_endpoint_url(endpoint),
37-
params={k: v for k, v in params.items() if v is not None},
38-
headers=headers,
39-
timeout=10.0,
40-
)
41-
response.raise_for_status()
42-
return response.json()
43-
except hishel.HTTPStatusError as e:
44-
try:
45-
error_data = response.json()
46-
raise HTTPException(
47-
status_code=response.status_code,
48-
detail=ApiErrorResponse(**error_data).error,
40+
41+
# Set cache control headers
42+
if cache:
43+
headers["Cache-Control"] = f"max-age={ttl}"
44+
else:
45+
headers["Cache-Control"] = "no-cache"
46+
47+
try:
48+
if cache:
49+
# Use hishel with caching (httpx-based)
50+
with hishel.CacheClient(storage=base_storage) as client:
51+
response = client.get(
52+
api_config.get_endpoint_url(endpoint),
53+
params=params,
54+
headers=headers,
55+
timeout=10.0,
4956
)
50-
except ValueError:
51-
raise HTTPException(
52-
status_code=response.status_code,
53-
detail="Torn API returned an error",
57+
else:
58+
# Bypass cache completely using regular httpx
59+
async with httpx.AsyncClient() as client:
60+
response = await client.get(
61+
api_config.get_endpoint_url(endpoint),
62+
params=params,
63+
headers=headers,
64+
timeout=10.0,
5465
)
55-
except hishel.RequestError as e:
56-
raise HTTPException(status_code=500, detail=f"Request error: {str(e)}")
66+
67+
response.raise_for_status()
68+
data = response.json()
69+
70+
if "error" in data:
71+
raise HTTPException(
72+
status_code=403,
73+
detail="Access level of this key is not high enough",
74+
)
75+
return data
76+
77+
except HTTPException:
78+
# Re-raise HTTP exceptions
79+
raise
80+
except httpx.HTTPStatusError as e:
81+
# Handle HTTP errors (4xx, 5xx)
82+
try:
83+
error_data = e.response.json()
84+
raise HTTPException(
85+
status_code=e.response.status_code,
86+
detail=ApiErrorResponse(**error_data).error,
87+
)
88+
except (ValueError, AttributeError):
89+
raise HTTPException(
90+
status_code=e.response.status_code,
91+
detail=f"Torn API returned an error: {e.response.text}",
92+
)
93+
except httpx.RequestError as e:
94+
# Handle connection errors, timeouts, etc.
95+
raise HTTPException(
96+
status_code=500,
97+
detail=f"Failed to connect to Torn API: {str(e)}"
98+
)
99+
except Exception as e:
100+
print(f"Unexpected error fetching Torn API: {e}")
101+
raise HTTPException(
102+
status_code=500,
103+
detail="An unexpected error occurred while calling Torn API"
104+
)
105+
57106

58107
async def get_api_key(authorization: str = Header(...)):
59108
"""Extract API key from Authorization header in 'Bearer' format."""
60109
if not authorization.startswith("Bearer "):
61-
raise HTTPException(status_code=401, detail="Invalid authorization header format. Expected 'Bearer <api_key>'")
110+
raise HTTPException(
111+
status_code=401,
112+
detail="Invalid authorization header format. Expected 'Bearer <api_key>'",
113+
)
62114
return authorization.replace("Bearer ", "").strip()
63115

116+
64117
__all__ = ["api_config", "fetch_torn_api", "get_api_key"]

backend/app/core/config.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pydantic_settings import BaseSettings
2+
3+
4+
class Settings(BaseSettings):
5+
"""
6+
Application configuration settings.
7+
8+
Attributes:
9+
REDIS_HOST (str): Hostname or IP address of Redis server.
10+
Default: "localhost"
11+
REDIS_PORT (int): Port number for Redis server connection.
12+
Default: 6379
13+
"""
14+
15+
REDIS_HOST: str = "redis"
16+
REDIS_PORT: int = 6379
17+
18+
class Config:
19+
env_file = ".env"
20+
21+
22+
settings = Settings()

backend/app/routers/revive_stats.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@ async def revives(
3535
HTTPException: If the API request fails or returns an error.
3636
"""
3737
params = {"timestamp": timestamp, "comment": comment}
38+
cache = False # don't cache first request
3839
if to_timestamp:
40+
cache = True # cache older records
3941
params.update({"to": to_timestamp})
4042
data = await fetch_torn_api(
41-
api_key=api_key, endpoint=api_config.REVIVES_ENDPOINT, params=params
43+
api_key=api_key, endpoint=api_config.REVIVES_ENDPOINT, params=params, cache=cache
4244
)
4345
return models.ReviveResponse(**data)
4446

@@ -66,8 +68,10 @@ async def revives(
6668
HTTPException: If the API request fails or returns an error.
6769
"""
6870
params = {"timestamp": timestamp, "comment": comment, "limit": 1000}
71+
72+
endpoint = api_config.REVIVES_FULL_ENDPOINT
6973
data = await fetch_torn_api(
70-
api_key=api_key, endpoint=api_config.REVIVES_FULL_ENDPOINT, params=params
74+
api_key=api_key, endpoint=endpoint, params=params, ttl=int(endpoint)
7175
)
7276
return models.ReviveResponseFull(**data)
7377

@@ -99,8 +103,9 @@ async def revive_stats(
99103
"comment": comment,
100104
"stat": "reviveskill,revives,revivesreceived",
101105
}
106+
endpoint = api_config.REVIVES_STATISTICS_ENDPOINT
102107
data = await fetch_torn_api(
103-
api_key=api_key, endpoint=api_config.REVIVES_STATISTICS_ENDPOINT, params=params
108+
api_key=api_key, endpoint=endpoint, params=params, ttl=int(endpoint)
104109
)
105110
return models.ReviveStats(**data)
106111

@@ -131,8 +136,9 @@ async def revive_skill_correlation(
131136
HTTPException: If the API request fails or returns an error.
132137
"""
133138
params = {"timestamp": timestamp, "comment": comment}
139+
endpoint = api_config.REVIVES_ENDPOINT
134140
data = await fetch_torn_api(
135-
api_key=api_key, endpoint=api_config.REVIVES_ENDPOINT, params=params
141+
api_key=api_key, endpoint=endpoint, params=params, ttl=int(endpoint)
136142
)
137143
try:
138144
corr, p_value = calculate_skill_success_correlation(data=data, my_id=user_id)
@@ -173,11 +179,14 @@ async def revives(
173179
"filter": "incoming",
174180
"limit": 100,
175181
},
182+
cache=False
176183
)
177184
target_incoming_revives = models.ReviveResponse(**data)
185+
endpoint = api_config.REVIVES_STATISTICS_ENDPOINT
178186
data = await fetch_torn_api(
179187
api_key=api_key,
180-
endpoint=api_config.REVIVES_STATISTICS_ENDPOINT,
188+
endpoint=endpoint,
189+
ttl=int(endpoint),
181190
params={"timestamp": timestamp, "comment": comment, "stat": "reviveskill,revives,revivesreceived"},
182191
)
183192
reviver_stats = models.ReviveStats(**data).personalstats

0 commit comments

Comments
 (0)