forked from cartsnitch/cartsnitch
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa893d9cc1 | |||
| 91c062130c | |||
| 0aef2455fd | |||
| 6602b8c105 | |||
| dbbc8d2e7b | |||
| 1267caf43c | |||
| 015401861a | |||
| 9891e1aefb | |||
| 69ad161e36 | |||
| 485f890df3 | |||
| bf3ed0ede3 | |||
| 3f41eb7346 | |||
| 6cbd1ef298 | |||
| 94214f762e | |||
| 562c6ef6f6 | |||
| ccc8189d88 | |||
| 86594e4a8e | |||
| c2f1a83c1d | |||
| 6f8e5a9577 | |||
| bbfa816e57 | |||
| 5904eb03a2 | |||
| 87b6433ff7 | |||
| d7c9938f7e | |||
| 02434060ee |
@@ -1,51 +1,26 @@
|
|||||||
"""Redis/DragonflyDB caching helpers."""
|
"""Redis/DragonflyDB caching helpers."""
|
||||||
|
|
||||||
import redis.asyncio as redis
|
|
||||||
|
|
||||||
from cartsnitch_api.config import settings
|
from cartsnitch_api.config import settings
|
||||||
|
|
||||||
|
|
||||||
class CacheClient:
|
class CacheClient:
|
||||||
"""Redis/DragonflyDB caching with connection pooling.
|
"""Stub for Redis/DragonflyDB caching.
|
||||||
|
|
||||||
Will be used for expensive queries: price trends, product comparisons.
|
Will be used for expensive queries: price trends, product comparisons.
|
||||||
Cache invalidation via Redis pub/sub events from other services.
|
Cache invalidation via Redis pub/sub events from other services.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._pool: redis.ConnectionPool | None = None
|
self.url = settings.redis_url
|
||||||
self._client: redis.Redis | None = None
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
"""Initialize the Redis connection pool."""
|
|
||||||
self._pool = redis.ConnectionPool.from_url(
|
|
||||||
settings.redis_url,
|
|
||||||
max_connections=20,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
self._client = redis.Redis(connection_pool=self._pool)
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
"""Close the Redis connection pool."""
|
|
||||||
if self._client:
|
|
||||||
await self._client.aclose()
|
|
||||||
if self._pool:
|
|
||||||
await self._pool.aclose()
|
|
||||||
|
|
||||||
async def get(self, key: str) -> str | None:
|
async def get(self, key: str) -> str | None:
|
||||||
if not self._client:
|
# TODO: implement with redis-py async
|
||||||
return None
|
return None
|
||||||
return await self._client.get(key)
|
|
||||||
|
|
||||||
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
|
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
|
||||||
if not self._client:
|
# TODO: implement with redis-py async
|
||||||
return
|
pass
|
||||||
await self._client.set(key, value, ex=ttl_seconds)
|
|
||||||
|
|
||||||
async def delete(self, key: str) -> None:
|
async def delete(self, key: str) -> None:
|
||||||
if not self._client:
|
# TODO: implement with redis-py async
|
||||||
return
|
pass
|
||||||
await self._client.delete(key)
|
|
||||||
|
|
||||||
|
|
||||||
cache_client = CacheClient()
|
|
||||||
|
|||||||
@@ -6,14 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
|||||||
|
|
||||||
from cartsnitch_api.config import settings
|
from cartsnitch_api.config import settings
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
settings.database_url,
|
|
||||||
echo=False,
|
|
||||||
pool_size=10,
|
|
||||||
max_overflow=20,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
pool_recycle=3600,
|
|
||||||
)
|
|
||||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
@@ -21,8 +14,3 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
"""FastAPI dependency that yields an async DB session."""
|
"""FastAPI dependency that yields an async DB session."""
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
async def dispose_engine() -> None:
|
|
||||||
"""Dispose the database engine, closing all pooled connections."""
|
|
||||||
await engine.dispose()
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import APIRouter, FastAPI
|
from fastapi import APIRouter, FastAPI
|
||||||
|
|
||||||
from cartsnitch_api.auth.routes import router as auth_router
|
from cartsnitch_api.auth.routes import router as auth_router
|
||||||
from cartsnitch_api.cache import cache_client
|
|
||||||
from cartsnitch_api.database import dispose_engine
|
|
||||||
from cartsnitch_api.middleware.cors import add_cors_middleware
|
from cartsnitch_api.middleware.cors import add_cors_middleware
|
||||||
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
|
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
|
||||||
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
|
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
|
||||||
@@ -25,10 +23,9 @@ from cartsnitch_api.routes.user import router as user_router
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await cache_client.initialize()
|
# TODO: initialize DB session pool, Redis connection, service clients
|
||||||
yield
|
yield
|
||||||
await cache_client.close()
|
# TODO: cleanup connections
|
||||||
await dispose_engine()
|
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
|||||||
+6
-12
@@ -4,23 +4,17 @@ import pg from "pg";
|
|||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString:
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||||
|
});
|
||||||
|
|
||||||
const secret = process.env.BETTER_AUTH_SECRET;
|
const secret = process.env.BETTER_AUTH_SECRET;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
throw new Error("BETTER_AUTH_SECRET environment variable is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
|
||||||
if (!databaseUrl) {
|
|
||||||
console.warn(
|
|
||||||
"WARNING: DATABASE_URL is not set — using default localhost connection. " +
|
|
||||||
"Set DATABASE_URL for production deployments."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pool = new Pool({
|
|
||||||
connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: pool,
|
database: pool,
|
||||||
basePath: "/auth",
|
basePath: "/auth",
|
||||||
|
|||||||
Generated
+3
-3
@@ -9805,9 +9805,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
"""Service-specific configuration for ReceiptWitness."""
|
"""Service-specific configuration for ReceiptWitness."""
|
||||||
|
|
||||||
from pydantic import model_validator
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
_PLACEHOLDER_VALUES = {"change-me-in-production"}
|
|
||||||
|
|
||||||
|
|
||||||
class ReceiptWitnessSettings(BaseSettings):
|
class ReceiptWitnessSettings(BaseSettings):
|
||||||
model_config = {"env_prefix": "RW_"}
|
model_config = {"env_prefix": "RW_"}
|
||||||
|
|
||||||
@@ -34,34 +30,5 @@ class ReceiptWitnessSettings(BaseSettings):
|
|||||||
# Mailgun inbound email webhook
|
# Mailgun inbound email webhook
|
||||||
mailgun_webhook_signing_key: str = ""
|
mailgun_webhook_signing_key: str = ""
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_required_vars(self):
|
|
||||||
errors = []
|
|
||||||
if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES:
|
|
||||||
errors.append(
|
|
||||||
"RW_SESSION_ENCRYPTION_KEY must be set to a secure value. "
|
|
||||||
'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
|
||||||
)
|
|
||||||
if self.notifications_enabled and not self.resend_api_key:
|
|
||||||
errors.append(
|
|
||||||
"RW_RESEND_API_KEY must be set when RW_NOTIFICATIONS_ENABLED=true. "
|
|
||||||
"Get an API key from https://resend.com/api-keys"
|
|
||||||
)
|
|
||||||
if errors:
|
|
||||||
raise ValueError(
|
|
||||||
"ReceiptWitness startup failed — missing required config:\n"
|
|
||||||
+ "\n".join(f" - {e}" for e in errors)
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
settings = ReceiptWitnessSettings()
|
||||||
class _LazySettings:
|
|
||||||
_instance: ReceiptWitnessSettings | None = None
|
|
||||||
|
|
||||||
def __getattr__(self, name: str):
|
|
||||||
if _LazySettings._instance is None:
|
|
||||||
_LazySettings._instance = ReceiptWitnessSettings()
|
|
||||||
return getattr(_LazySettings._instance, name)
|
|
||||||
|
|
||||||
|
|
||||||
settings = _LazySettings()
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
"""Shared test fixtures."""
|
"""Shared test fixtures."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
os.environ.setdefault("RW_SESSION_ENCRYPTION_KEY", "test-secret-key-for-unit-tests-only-32bytes!")
|
|
||||||
os.environ.setdefault("RW_MAILGUN_WEBHOOK_SIGNING_KEY", "test-mailgun-signing-key")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def meijer_receipt_data() -> dict:
|
def meijer_receipt_data() -> dict:
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from receiptwitness.config import ReceiptWitnessSettings
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_config():
|
|
||||||
s = ReceiptWitnessSettings(
|
|
||||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
|
||||||
)
|
|
||||||
assert s.session_encryption_key
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_session_encryption_key_raises():
|
|
||||||
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
|
|
||||||
ReceiptWitnessSettings(session_encryption_key="")
|
|
||||||
|
|
||||||
|
|
||||||
def test_placeholder_session_encryption_key_raises():
|
|
||||||
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
|
|
||||||
ReceiptWitnessSettings(session_encryption_key="change-me-in-production")
|
|
||||||
|
|
||||||
|
|
||||||
def test_notifications_enabled_without_resend_key_raises():
|
|
||||||
with pytest.raises(ValueError, match="RW_RESEND_API_KEY"):
|
|
||||||
ReceiptWitnessSettings(
|
|
||||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
|
||||||
notifications_enabled=True,
|
|
||||||
resend_api_key="",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_notifications_disabled_without_resend_key_ok():
|
|
||||||
s = ReceiptWitnessSettings(
|
|
||||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
|
||||||
notifications_enabled=False,
|
|
||||||
resend_api_key="",
|
|
||||||
)
|
|
||||||
assert s.notifications_enabled is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_notifications_enabled_with_resend_key_ok():
|
|
||||||
s = ReceiptWitnessSettings(
|
|
||||||
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
|
|
||||||
notifications_enabled=True,
|
|
||||||
resend_api_key="re_test_1234567890",
|
|
||||||
)
|
|
||||||
assert s.resend_api_key == "re_test_1234567890"
|
|
||||||
Reference in New Issue
Block a user