Multi-Source Integration Phase 3.2. /api/v1/search with cross-source filters + POST body params + Redis 5min hot cache. New files: app/schemas/search.py — SearchParams (~20 filters, Pydantic v2) app/schemas/search_response.py — SearchResponse + SearchResultItem app/services/search_query.py — build_search_query (psycopg v3, CAST(:x AS type)) app/services/cache.py — SearchCache async wrapper (singleton lru_cache) app/api/v1/search.py — POST /search endpoint tests/test_search_api.py — unit + integration tests Edited: app/main.py — include search.router prefix=/api/v1 app/core/config.py — settings.redis_url default pyproject.toml — add redis>=5.0.0 Queries listings_search_mv (matview from PR #469). Cache failures swallowed (degrade to slow-but-correct). Sort whitelisted dict (no SQL injection). Refs Master Plan sec 9.1 + 7.2.
64 lines
2 KiB
Python
64 lines
2 KiB
Python
"""Search endpoint — POST /api/v1/search (Phase 3.2, master plan sec 9.1)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.db import get_db
|
|
from app.schemas.search import SearchParams
|
|
from app.schemas.search_response import SearchResponse, SearchResultItem
|
|
from app.services.cache import get_search_cache
|
|
from app.services.search_query import build_count_query, build_search_query
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.post("/search", response_model=SearchResponse)
|
|
async def search(
|
|
params: SearchParams,
|
|
db: Annotated[Session, Depends(get_db)],
|
|
) -> SearchResponse:
|
|
"""Поиск listings по фильтрам (cross-source matview + Redis 5min cache)."""
|
|
started = time.monotonic()
|
|
cache = get_search_cache()
|
|
cache_key = cache.search_key(params.model_dump(mode="json"))
|
|
|
|
cached = await cache.get(cache_key)
|
|
if cached is not None:
|
|
logger.info(
|
|
"search cache HIT key=%s page=%d size=%d",
|
|
cache_key[:24], params.page, params.page_size,
|
|
)
|
|
return SearchResponse.model_validate(cached)
|
|
|
|
sql, args = build_search_query(params)
|
|
rows = db.execute(text(sql), args).mappings().all()
|
|
|
|
count_sql, count_args = build_count_query(params)
|
|
total = int(db.execute(text(count_sql), count_args).scalar() or 0)
|
|
|
|
items = [SearchResultItem.model_validate(dict(r)) for r in rows]
|
|
elapsed_ms = (time.monotonic() - started) * 1000.0
|
|
response = SearchResponse(
|
|
items=items,
|
|
total=total,
|
|
page=params.page,
|
|
page_size=params.page_size,
|
|
elapsed_ms=round(elapsed_ms, 1),
|
|
cache_hit=False,
|
|
)
|
|
|
|
await cache.set(cache_key, response.model_dump(mode="json"), ttl=cache.TTL_SEARCH)
|
|
logger.info(
|
|
"search MISS page=%d size=%d total=%d items=%d elapsed=%.1fms",
|
|
params.page, params.page_size, total, len(items), elapsed_ms,
|
|
)
|
|
return response
|