Skip to content

Commit 4d23485

Browse files
authored
Merge pull request #156 from spoo-me/refactor/repositories-dtos
feat: implement service wiring and enhance repository error handling
2 parents 2213f91 + fcd44f3 commit 4d23485

52 files changed

Lines changed: 1372 additions & 1620 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from starlette.middleware.sessions import SessionMiddleware
1919

2020
from config import AppSettings
21+
from dependencies.wiring import wire_services
2122
from infrastructure.email.zeptomail import ZeptoMailProvider
2223
from infrastructure.geoip import GeoIPService
2324
from infrastructure.http_client import HttpClient
@@ -111,7 +112,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
111112
app.state.oauth_providers = oauth_providers
112113

113114
# Shared HTTP client + email provider — singletons to preserve connection pooling
114-
http_client = HttpClient(timeout=5.0)
115+
http_client = HttpClient(timeout=settings.http_client_timeout)
115116
app.state.http_client = http_client
116117
app.state.email_provider = ZeptoMailProvider(
117118
settings.email, http_client, app_url=settings.app_url
@@ -124,6 +125,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
124125

125126
await ensure_indexes(app.state.db)
126127

128+
# ── Build all repos + services (composition root) ────────────────
129+
wire_services(app, settings, redis_client)
130+
127131
# Warn if session secret is missing when auth is enabled
128132
if settings.jwt and not settings.secret_key:
129133
log.warning(

config.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from urllib.parse import urlparse
1414

15-
from pydantic import model_validator
15+
from pydantic import field_validator, model_validator
1616
from pydantic_settings import BaseSettings, SettingsConfigDict
1717

1818

@@ -148,6 +148,50 @@ class AppSettings(BaseSettings):
148148
hcaptcha_secret: str = ""
149149
hcaptcha_sitekey: str = ""
150150

151+
# Service limits (overridable by self-hosters via env vars)
152+
max_active_api_keys: int = 20
153+
max_date_range_days: int = 90
154+
http_client_timeout: float = 5.0
155+
156+
# Validator constraints (overridable by self-hosters via env vars)
157+
blocked_url_regex_timeout: float = 0.2
158+
max_emoji_alias_length: int = 15
159+
url_password_min_length: int = 8
160+
account_password_min_length: int = 8
161+
account_password_max_length: int = 128
162+
163+
# ── Field validators for safety-critical config ────────────────────
164+
165+
@field_validator(
166+
"max_active_api_keys", "max_date_range_days", "max_emoji_alias_length"
167+
)
168+
@classmethod
169+
def _must_be_positive_int(cls, v: int, info) -> int:
170+
if v < 1:
171+
raise ValueError(f"{info.field_name} must be >= 1, got {v}")
172+
return v
173+
174+
@field_validator("http_client_timeout", "blocked_url_regex_timeout")
175+
@classmethod
176+
def _must_be_positive_float(cls, v: float, info) -> float:
177+
if v <= 0:
178+
raise ValueError(f"{info.field_name} must be > 0, got {v}")
179+
return v
180+
181+
@field_validator("url_password_min_length", "account_password_min_length")
182+
@classmethod
183+
def _password_min_length_sane(cls, v: int, info) -> int:
184+
if v < 1:
185+
raise ValueError(f"{info.field_name} must be >= 1, got {v}")
186+
return v
187+
188+
@field_validator("account_password_max_length")
189+
@classmethod
190+
def _password_max_length_sane(cls, v: int) -> int:
191+
if v < 1:
192+
raise ValueError(f"account_password_max_length must be >= 1, got {v}")
193+
return v
194+
151195
# Sub-configs (composed via model_validator below)
152196
db: DatabaseSettings | None = None
153197
redis: RedisSettings | None = None
@@ -159,6 +203,13 @@ class AppSettings(BaseSettings):
159203

160204
@model_validator(mode="after")
161205
def _populate_sub_configs_and_secret(self) -> AppSettings:
206+
# Cross-field validation
207+
if self.account_password_max_length < self.account_password_min_length:
208+
raise ValueError(
209+
f"account_password_max_length ({self.account_password_max_length}) "
210+
f"must be >= account_password_min_length ({self.account_password_min_length})"
211+
)
212+
162213
# Accept FLASK_SECRET_KEY as a fallback for backward compatibility
163214
if not self.secret_key and self.flask_secret_key:
164215
self.secret_key = self.flask_secret_key

dependencies/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
get_geoip_service,
3333
get_redis,
3434
get_settings,
35-
get_url_cache,
3635
)
3736
from dependencies.services import (
3837
get_api_key_service,
@@ -75,7 +74,6 @@
7574
"get_redis",
7675
"get_settings",
7776
"get_stats_service",
78-
"get_url_cache",
7977
"get_url_service",
8078
"optional_scopes",
8179
"optional_scopes_verified",

dependencies/infra.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,9 @@
77

88
from __future__ import annotations
99

10-
from fastapi import Depends, Request
10+
from fastapi import Request
1111

1212
from config import AppSettings
13-
from infrastructure.cache.url_cache import UrlCache
1413
from infrastructure.geoip import GeoIPService
1514

1615

@@ -37,11 +36,3 @@ def get_email_provider(request: Request):
3736
def get_geoip_service(request: Request) -> GeoIPService:
3837
"""Return the GeoIPService singleton from app.state."""
3938
return request.app.state.geoip
40-
41-
42-
def get_url_cache(
43-
redis=Depends(get_redis),
44-
settings: AppSettings = Depends(get_settings),
45-
) -> UrlCache:
46-
"""Return a UrlCache wrapping the shared Redis client."""
47-
return UrlCache(redis, ttl_seconds=settings.redis.redis_ttl_seconds)

dependencies/services.py

Lines changed: 41 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,57 @@
11
"""
2-
Service and repository dependency providers.
2+
Service dependency providers.
33
4-
Each function assembles the repositories and infrastructure a service needs,
5-
then returns a fully-wired service instance for the current request.
4+
Each function is a thin lookup that returns the singleton service instance
5+
built during application startup in the lifespan (app.py). No per-request
6+
object construction — services are stateless and shared across requests.
67
"""
78

89
from __future__ import annotations
910

10-
from fastapi import Depends, Request
11-
12-
from config import AppSettings
13-
from dependencies.infra import (
14-
get_db,
15-
get_email_provider,
16-
get_geoip_service,
17-
get_settings,
18-
get_url_cache,
19-
)
20-
from infrastructure.cache.url_cache import UrlCache
21-
from infrastructure.captcha.hcaptcha import HCaptchaProvider
22-
from infrastructure.geoip import GeoIPService
23-
from infrastructure.webhook.discord import DiscordWebhookProvider
24-
from repositories.api_key_repository import ApiKeyRepository
25-
from repositories.blocked_url_repository import BlockedUrlRepository
26-
from repositories.click_repository import ClickRepository
27-
from repositories.legacy.emoji_url_repository import EmojiUrlRepository
28-
from repositories.legacy.legacy_url_repository import LegacyUrlRepository
29-
from repositories.token_repository import TokenRepository
30-
from repositories.url_repository import UrlRepository
31-
from repositories.user_repository import UserRepository
11+
from fastapi import Request
12+
3213
from services.api_key_service import ApiKeyService
3314
from services.auth_service import AuthService
34-
from services.click import ClickService, LegacyClickHandler, V2ClickHandler
15+
from services.click import ClickService
3516
from services.contact_service import ContactService
36-
from services.export.formatters import default_formatters
3717
from services.export.service import ExportService
3818
from services.oauth_service import OAuthService
3919
from services.profile_picture_service import ProfilePictureService
4020
from services.stats_service import StatsService
4121
from services.url_service import UrlService
4222

4323

44-
async def get_url_service(
45-
db=Depends(get_db),
46-
url_cache: UrlCache = Depends(get_url_cache),
47-
settings: AppSettings = Depends(get_settings),
48-
) -> UrlService:
49-
url_repo = UrlRepository(db["urlsV2"])
50-
legacy_repo = LegacyUrlRepository(db["urls"])
51-
emoji_repo = EmojiUrlRepository(db["emojis"])
52-
blocked_url_repo = BlockedUrlRepository(db["blocked-urls"])
53-
blocked_self_domains = [settings.app_url] if settings.app_url else []
54-
return UrlService(
55-
url_repo,
56-
legacy_repo,
57-
emoji_repo,
58-
blocked_url_repo,
59-
url_cache,
60-
blocked_self_domains,
61-
)
62-
63-
64-
async def get_stats_service(db=Depends(get_db)) -> StatsService:
65-
click_repo = ClickRepository(db["clicks"])
66-
url_repo = UrlRepository(db["urlsV2"])
67-
return StatsService(click_repo, url_repo)
68-
69-
70-
async def get_export_service(
71-
stats: StatsService = Depends(get_stats_service),
72-
) -> ExportService:
73-
return ExportService(stats, default_formatters())
74-
75-
76-
async def get_api_key_service(db=Depends(get_db)) -> ApiKeyService:
77-
api_key_repo = ApiKeyRepository(db["api-keys"])
78-
return ApiKeyService(api_key_repo)
79-
80-
81-
async def get_auth_service(
82-
db=Depends(get_db),
83-
settings: AppSettings = Depends(get_settings),
84-
email=Depends(get_email_provider),
85-
) -> AuthService:
86-
"""Build and return an AuthService for the current request."""
87-
user_repo = UserRepository(db["users"])
88-
token_repo = TokenRepository(db["verification-tokens"])
89-
return AuthService(user_repo, token_repo, email, settings.jwt)
90-
91-
92-
async def get_oauth_service(
93-
db=Depends(get_db),
94-
auth_service: AuthService = Depends(get_auth_service),
95-
email=Depends(get_email_provider),
96-
) -> OAuthService:
97-
"""Build and return an OAuthService for the current request."""
98-
user_repo = UserRepository(db["users"])
99-
return OAuthService(user_repo, auth_service, email)
100-
101-
102-
async def get_profile_picture_service(db=Depends(get_db)) -> ProfilePictureService:
103-
return ProfilePictureService(UserRepository(db["users"]))
104-
105-
106-
async def get_contact_service(
107-
request: Request,
108-
settings: AppSettings = Depends(get_settings),
109-
) -> ContactService:
110-
http_client = request.app.state.http_client
111-
captcha = HCaptchaProvider(settings.hcaptcha_secret, http_client)
112-
contact_webhook = DiscordWebhookProvider(settings.contact_webhook, http_client)
113-
report_webhook = DiscordWebhookProvider(settings.url_report_webhook, http_client)
114-
return ContactService(contact_webhook, report_webhook, captcha)
115-
116-
117-
async def get_click_service(
118-
db=Depends(get_db),
119-
url_cache: UrlCache = Depends(get_url_cache),
120-
geoip: GeoIPService = Depends(get_geoip_service),
121-
) -> ClickService:
122-
"""Build and return a ClickService with V2 and legacy handlers."""
123-
url_repo = UrlRepository(db["urlsV2"])
124-
legacy_repo = LegacyUrlRepository(db["urls"])
125-
emoji_repo = EmojiUrlRepository(db["emojis"])
126-
click_repo = ClickRepository(db["clicks"])
127-
v2_handler = V2ClickHandler(click_repo, url_repo, geoip, url_cache)
128-
v1_handler = LegacyClickHandler(legacy_repo, emoji_repo, geoip)
129-
return ClickService({"v2": v2_handler, "v1": v1_handler})
24+
def get_url_service(request: Request) -> UrlService:
25+
return request.app.state.url_service
26+
27+
28+
def get_stats_service(request: Request) -> StatsService:
29+
return request.app.state.stats_service
30+
31+
32+
def get_export_service(request: Request) -> ExportService:
33+
return request.app.state.export_service
34+
35+
36+
def get_api_key_service(request: Request) -> ApiKeyService:
37+
return request.app.state.api_key_service
38+
39+
40+
def get_auth_service(request: Request) -> AuthService:
41+
return request.app.state.auth_service
42+
43+
44+
def get_oauth_service(request: Request) -> OAuthService:
45+
return request.app.state.oauth_service
46+
47+
48+
def get_profile_picture_service(request: Request) -> ProfilePictureService:
49+
return request.app.state.profile_picture_service
50+
51+
52+
def get_contact_service(request: Request) -> ContactService:
53+
return request.app.state.contact_service
54+
55+
56+
def get_click_service(request: Request) -> ClickService:
57+
return request.app.state.click_service

0 commit comments

Comments
 (0)