Files
hey-search/backend/app/main.py
T
junvandCopilot e9a5d68553 feat: Redis search cache with configurable TTL
- Add optional Redis caching for search results (REDIS_URL env var)
- Cache TTL configurable via UI: 0h (disabled) to 168h (1 week), default 6h
- New backend modules: cache.py (async Redis get/set/flush), settings.py (SQLite settings table)
- New API endpoints: GET/PUT /api/settings, DELETE /api/cache
- New 'Cache' tab in Settings modal with status indicator, preset buttons, slider, and flush
- Cache keys are deterministic hashes of query+category+page+image_size+engines
- Redis is fully optional: graceful no-op when REDIS_URL unset or Redis unreachable
- Updated Dockerfile, README, CHANGELOG, FEATURES

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-23 17:26:14 +11:00

124 lines
3.6 KiB
Python

"""Hey Search - A metasearch engine."""
import json
import os
import time
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.openapi.utils import get_openapi
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.api.routes import router
from app.engines import registry
from app.excluded import init_db
from app.settings import init_settings_table
from app.cache import init_redis, close_redis
from app.models import APIError
@asynccontextmanager
async def lifespan(application: FastAPI):
init_db()
init_settings_table()
registry.load_default_engines()
await init_redis()
yield
await close_redis()
app = FastAPI(
title="Hey Search",
description="A privacy-respecting metasearch engine. See endpoints below for usage with curl examples.",
version="1.4.0",
lifespan=lifespan,
# Disable the default /openapi.json — we serve a dynamic one below
openapi_url=None,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Cache the base schema (without servers) so we only compute it once
_openapi_schema_cache: dict | None = None
def _get_base_openapi_schema() -> dict:
"""Generate the OpenAPI schema once and cache it."""
global _openapi_schema_cache
if _openapi_schema_cache is None:
_openapi_schema_cache = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)
return _openapi_schema_cache
@app.get("/openapi.json", include_in_schema=False)
async def dynamic_openapi(request: Request):
"""Serve OpenAPI schema with servers[] matching the caller's origin."""
base = _get_base_openapi_schema()
# Derive the base URL from the request
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host") or request.headers.get("host", "localhost")
base_url = f"{proto}://{host}"
# Deep-replace $BASE_URL in all description strings and set servers
raw = json.dumps(base)
raw = raw.replace("$BASE_URL", base_url)
schema = json.loads(raw)
schema["servers"] = [{"url": base_url, "description": "Current server"}]
return schema
# Wire Swagger UI and Redoc to our dynamic endpoint
app.openapi_url = "/openapi.json"
app.setup()
@app.middleware("http")
async def add_rate_limit_headers(request: Request, call_next):
"""Add rate-limit placeholder and timing headers."""
start = time.monotonic()
response = await call_next(request)
elapsed_ms = round((time.monotonic() - start) * 1000)
response.headers["X-Response-Time-Ms"] = str(elapsed_ms)
response.headers["X-RateLimit-Limit"] = "60"
response.headers["X-RateLimit-Remaining"] = "59"
return response
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Return standardized JSON errors."""
return JSONResponse(
status_code=500,
content=APIError(
code="internal_error",
message="An unexpected error occurred",
details=str(exc),
retry_hint="Try again later",
).model_dump(),
)
app.include_router(router, prefix="/api")
# Serve frontend static files if they exist (production / Docker)
static_dir = Path(__file__).resolve().parent.parent / "static"
if static_dir.is_dir():
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")