Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: CI

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

jobs:
backend:
name: Backend (pytest)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: backend/requirements.txt

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Run tests
run: |
pytest -q

frontend:
name: Frontend (lint, test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Lint
run: npm run lint

- name: Test (vitest coverage)
run: npm test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,6 @@ docs/_build/

# Search index
search_index/

# Claude local artifacts
codeagent_wrapper_smoke_test.md
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,42 @@

## Quick Start

Backend (Terminal 1):

```bash
# Backend
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload
uvicorn app.main:app --reload
```

Frontend (Terminal 2):

# Frontend
```bash
cd frontend
npm install
npm run dev
```

## Docker (Dev)

```bash
# From repo root
docker compose up

# API health
curl http://localhost:8000/api/health
```

## Project Structure

```
doc-search/
├── backend/ # FastAPI backend
├── frontend/ # React frontend
├── docs/ # Documentation
├── .github/ # CI (GitHub Actions)
└── docker-compose.yml
```

Expand Down
19 changes: 19 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Backend
# Defaults are defined in backend/app/core/config.py and can be overridden by env vars.

# sqlite (default)
DATABASE_URL=sqlite+aiosqlite:///./doc_search.db

# Storage
UPLOAD_DIR=./uploads
INDEX_DIR=./search_index

# CORS
# Accepts JSON array or comma-separated list.
# Examples:
# CORS_ORIGINS=["http://localhost:5173"]
# CORS_ORIGINS=http://localhost:5173,http://example.com
CORS_ORIGINS=["*"]

# Credentials (cookies/Authorization). Default: false.
CORS_ALLOW_CREDENTIALS=false
61 changes: 61 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,75 @@
from __future__ import annotations

import json
import os
from dataclasses import dataclass, field


from typing import Optional


def _parse_bool(value: str) -> Optional[bool]:
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return None


def _parse_cors_origins(value: str) -> Optional[list[str]]:
raw = value.strip()
if not raw:
return None

# Prefer JSON array syntax: ["http://a", "http://b"]
if raw.startswith("["):
try:
parsed = json.loads(raw)
except Exception:
return None
if not isinstance(parsed, list):
return None
items = [str(x).strip() for x in parsed]
return [x for x in items if x]

# Fallback: comma-separated list: http://a, http://b
items = [part.strip() for part in raw.split(",")]
return [x for x in items if x]


@dataclass
class Settings:
DATABASE_URL: str = "sqlite+aiosqlite:///./doc_search.db"
UPLOAD_DIR: str = "./uploads"
INDEX_DIR: str = "./search_index"
CORS_ORIGINS: list[str] = field(default_factory=lambda: ["*"])
CORS_ALLOW_CREDENTIALS: bool = False

def __post_init__(self) -> None:
database_url = os.environ.get("DATABASE_URL")
if database_url:
self.DATABASE_URL = database_url

upload_dir = os.environ.get("UPLOAD_DIR")
if upload_dir:
self.UPLOAD_DIR = upload_dir

index_dir = os.environ.get("INDEX_DIR")
if index_dir:
self.INDEX_DIR = index_dir

cors_origins = os.environ.get("CORS_ORIGINS")
if cors_origins:
parsed_origins = _parse_cors_origins(cors_origins)
if parsed_origins is not None:
self.CORS_ORIGINS = parsed_origins

cors_allow_credentials = os.environ.get("CORS_ALLOW_CREDENTIALS")
if cors_allow_credentials is not None:
parsed_bool = _parse_bool(cors_allow_credentials)
if parsed_bool is not None:
self.CORS_ALLOW_CREDENTIALS = parsed_bool


settings = Settings()
7 changes: 6 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@ async def lifespan(_: FastAPI):

app = FastAPI(title="Doc Search API", lifespan=lifespan)

allow_credentials = settings.CORS_ALLOW_CREDENTIALS
if allow_credentials and "*" in settings.CORS_ORIGINS:
# Wildcard origins cannot be used with credentials.
allow_credentials = False

app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_credentials=allow_credentials,
allow_methods=["*"],
allow_headers=["*"],
)
Expand Down
20 changes: 19 additions & 1 deletion backend/app/routers/health.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from fastapi import APIRouter
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.database import get_db
from app.services import search_service

router = APIRouter(tags=["health"])

Expand All @@ -11,3 +16,16 @@ async def health_check():
@router.get("/api/health")
async def api_health_check():
return {"status": "healthy", "version": "1.0.0"}


@router.get("/api/health/ready")
async def ready_check(db: AsyncSession = Depends(get_db)):
try:
await db.execute(text("SELECT 1"))
except Exception as exc:
raise HTTPException(status_code=503, detail="Database not ready") from exc

if not search_service._SEARCH_BACKEND_AVAILABLE:
raise HTTPException(status_code=503, detail="Search backend not ready")

return {"status": "ready"}
57 changes: 50 additions & 7 deletions backend/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,62 @@
from app.core.config import Settings, settings
import pytest

from app.core.config import Settings


@pytest.fixture(autouse=True)
def clear_settings_env(monkeypatch):
# Ensure deterministic tests regardless of developer machine env.
for key in [
"DATABASE_URL",
"UPLOAD_DIR",
"INDEX_DIR",
"CORS_ORIGINS",
"CORS_ALLOW_CREDENTIALS",
]:
monkeypatch.delenv(key, raising=False)


def test_settings_loads_with_defaults():
loaded = Settings()
assert loaded.DATABASE_URL == "sqlite+aiosqlite:///./doc_search.db"
assert loaded.UPLOAD_DIR == "./uploads"
assert loaded.INDEX_DIR == "./search_index"
assert loaded.CORS_ORIGINS == ["*"]
assert loaded.CORS_ALLOW_CREDENTIALS is False


def test_database_url_is_set():
assert isinstance(settings.DATABASE_URL, str)
assert settings.DATABASE_URL
def test_database_url_env_override(monkeypatch):
monkeypatch.setenv("DATABASE_URL", "sqlite+aiosqlite:///./override.db")
loaded = Settings()
assert loaded.DATABASE_URL == "sqlite+aiosqlite:///./override.db"


def test_upload_dir_env_override(monkeypatch):
monkeypatch.setenv("UPLOAD_DIR", "./tmp_uploads")
loaded = Settings()
assert loaded.UPLOAD_DIR == "./tmp_uploads"


def test_index_dir_env_override(monkeypatch):
monkeypatch.setenv("INDEX_DIR", "./tmp_index")
loaded = Settings()
assert loaded.INDEX_DIR == "./tmp_index"

def test_upload_dir_is_set():
assert isinstance(settings.UPLOAD_DIR, str)
assert settings.UPLOAD_DIR

def test_cors_origins_json_array(monkeypatch):
monkeypatch.setenv("CORS_ORIGINS", '["http://localhost:5173", "http://example.com"]')
loaded = Settings()
assert loaded.CORS_ORIGINS == ["http://localhost:5173", "http://example.com"]


def test_cors_origins_comma_separated(monkeypatch):
monkeypatch.setenv("CORS_ORIGINS", "http://a, http://b, ,")
loaded = Settings()
assert loaded.CORS_ORIGINS == ["http://a", "http://b"]


def test_cors_allow_credentials_parsing(monkeypatch):
monkeypatch.setenv("CORS_ALLOW_CREDENTIALS", "true")
loaded = Settings()
assert loaded.CORS_ALLOW_CREDENTIALS is True

43 changes: 43 additions & 0 deletions backend/tests/test_health.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import pytest

from app.core.config import settings
from app.core.database import get_db
from app.main import app, lifespan
from app.services import search_service


@pytest.mark.asyncio
Expand All @@ -18,6 +20,47 @@ async def test_get_api_health_returns_200_and_correct_json(client):
assert response.json() == {"status": "healthy", "version": "1.0.0"}


@pytest.mark.asyncio
async def test_get_ready_returns_200_and_correct_json(client, monkeypatch):
monkeypatch.setattr(search_service, "_SEARCH_BACKEND_AVAILABLE", True)
response = await client.get("/api/health/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready"}


@pytest.mark.asyncio
async def test_get_ready_returns_503_when_db_not_ready(client, monkeypatch):
monkeypatch.setattr(search_service, "_SEARCH_BACKEND_AVAILABLE", True)

class FailingSession:
async def execute(self, *args, **kwargs):
raise RuntimeError("db not ready")

async def override_get_db():
yield FailingSession()

previous_override = app.dependency_overrides.get(get_db)
app.dependency_overrides[get_db] = override_get_db
try:
response = await client.get("/api/health/ready")
finally:
if previous_override is None:
app.dependency_overrides.pop(get_db, None)
else:
app.dependency_overrides[get_db] = previous_override

assert response.status_code == 503
assert response.json()["detail"] == "Database not ready"


@pytest.mark.asyncio
async def test_get_ready_returns_503_when_search_backend_not_ready(client, monkeypatch):
monkeypatch.setattr(search_service, "_SEARCH_BACKEND_AVAILABLE", False)
response = await client.get("/api/health/ready")
assert response.status_code == 503
assert response.json()["detail"] == "Search backend not ready"


@pytest.mark.asyncio
async def test_lifespan_creates_upload_dir(tmp_path, monkeypatch):
upload_dir = tmp_path / "uploads"
Expand Down
Loading