← back to Unclaimed Property Platform
services/search/service.py
52 lines
"""SearchService — framework-agnostic anonymous-search core (stdlib).
Composes the anti-enumeration + privacy controls in ONE place so every transport (FastAPI,
a stdlib http.server, tests) enforces them identically:
validate query -> rate-limit (per trusted client key) -> masked_search
-> assert_public_safe on every row -> masked projection out
The authoritative property table is never queried directly by transports; they call this.
"""
from __future__ import annotations
from dataclasses import dataclass
from services.common.public_projection import assert_public_safe
from services.common.rate_limit import SlidingWindowRateLimiter
class EmptyQuery(ValueError):
"""Query had no alphanumeric token — rejected before hitting the index."""
class RateLimited(Exception):
def __init__(self, retry_after: float) -> None:
super().__init__(f"rate limited; retry after {retry_after:.1f}s")
self.retry_after = retry_after
@dataclass
class SearchService:
repository: object # provides masked_search(query, limit)
limiter: SlidingWindowRateLimiter
max_results: int = 20
def search(self, query: str, client_key: str, limit: int | None = None) -> list[dict]:
# 1) anti-enumeration: rate-limit BEFORE any work, keyed by trusted client id.
if not self.limiter.allow(client_key):
raise RateLimited(self.limiter.retry_after(client_key))
# 2) reject empty/punctuation-only queries (mass-enumeration primitive).
if not query or not query.strip():
raise EmptyQuery("search query must contain at least one alphanumeric token")
cap = min(limit or self.max_results, self.max_results)
try:
rows = self.repository.masked_search(query, limit=cap)
except ValueError as exc: # repository's own empty-query guard
raise EmptyQuery(str(exc)) from exc
# 3) fail closed: every row must be an allowlisted masked projection.
return [assert_public_safe(r) for r in rows]