Restructure backend plan to FastAPI's official routers/ + dependencies.py layout

This commit is contained in:
Tianyang 2026-08-01 02:01:28 +08:00
parent ba7ee21c28
commit 954f7ac367

View File

@ -701,7 +701,7 @@ git commit -m "feat: add MinioClient wrapper for list/get/put/delete object oper
**Interfaces:** **Interfaces:**
- Consumes: `meilisearch.Client` SDK instance (constructor-injected; tests inject a hand-written fake) - Consumes: `meilisearch.Client` SDK instance (constructor-injected; tests inject a hand-written fake)
- Produces: `SearchClient(client, index_name: str)` with `index_document(key: str, path: str, title: str, content: str) -> None`, `delete_document(key: str) -> None`, `search(query: str) -> list[dict]` (each dict has `key`, `title`, `path`, `snippet`) — consumed by `DocumentService` in Task 5 and `routes/search.py` in Task 7. - Produces: `SearchClient(client, index_name: str)` with `index_document(key: str, path: str, title: str, content: str) -> None`, `delete_document(key: str) -> None`, `search(query: str) -> list[dict]` (each dict has `key`, `title`, `path`, `snippet`) — consumed by `DocumentService` in Task 5 and `routers/search.py` in Task 7.
- [ ] **Step 1: Write the failing test for `index_document`** - [ ] **Step 1: Write the failing test for `index_document`**
@ -901,7 +901,7 @@ git commit -m "feat: add SearchClient wrapper for index/delete/search operations
**Interfaces:** **Interfaces:**
- Consumes: `MinioClient.list_objects/get_object/put_object/delete_object` (Task 3), `SearchClient.index_document/delete_document` (Task 4), `markdown_to_text`/`html_to_text` (Task 2) - Consumes: `MinioClient.list_objects/get_object/put_object/delete_object` (Task 3), `SearchClient.index_document/delete_document` (Task 4), `markdown_to_text`/`html_to_text` (Task 2)
- Produces: `DocumentService(minio_client, search_client)` with `list_documents(path: str) -> dict`, `get_document(key: str) -> dict`, `save_document(key: str, content: bytes, content_type: str) -> dict`, `delete_document(key: str) -> None`, `upload_document(key: str, data: bytes, content_type: str) -> dict`; `DocumentNotFoundError` exception — all consumed by `routes/documents.py` in Task 6. - Produces: `DocumentService(minio_client, search_client)` with `list_documents(path: str) -> dict`, `get_document(key: str) -> dict`, `save_document(key: str, content: bytes, content_type: str) -> dict`, `delete_document(key: str) -> None`, `upload_document(key: str, data: bytes, content_type: str) -> dict`; `DocumentNotFoundError` exception — all consumed by `routers/documents.py` in Task 6.
- [ ] **Step 1: Write the failing test for `list_documents`** - [ ] **Step 1: Write the failing test for `list_documents`**
@ -1239,26 +1239,27 @@ git commit -m "feat: add DocumentService coordinating MinIO writes and search in
--- ---
### Task 6: Documents Routes (`routes/documents.py`) ### Task 6: Documents Router (`routers/documents.py`)
**Files:** **Files:**
- Create: `backend/app/routes/__init__.py` - Create: `backend/app/dependencies.py`
- Create: `backend/app/routes/documents.py` - Create: `backend/app/routers/__init__.py`
- Test: `backend/tests/test_routes_documents.py` - Create: `backend/app/routers/documents.py`
- Test: `backend/tests/test_routers_documents.py`
**Interfaces:** **Interfaces:**
- Consumes: `DocumentService`, `DocumentNotFoundError` (Task 5), `app.config.settings` (Task 1) - Consumes: `DocumentService`, `DocumentNotFoundError` (Task 5), `app.config.settings` (Task 1)
- Produces: `router` (APIRouter with `GET /api/documents`, `GET /api/documents/{key:path}`, `PUT /api/documents/{key:path}`, `DELETE /api/documents/{key:path}`, `POST /api/upload`), `get_document_service()` dependency provider — both consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`. - Produces: `app.dependencies.get_document_service()` dependency provider (FastAPI's official "Bigger Applications" layout keeps shared dependency providers in a dedicated `dependencies.py`, separate from routers); `router` (APIRouter with `GET /api/documents`, `GET /api/documents/{key:path}`, `PUT /api/documents/{key:path}`, `DELETE /api/documents/{key:path}`, `POST /api/upload`) — both consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`.
- [ ] **Step 1: Write the failing test for `GET /api/documents`** - [ ] **Step 1: Write the failing test for `GET /api/documents`**
```python ```python
# backend/tests/test_routes_documents.py # backend/tests/test_routers_documents.py
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
from app.routes.documents import get_document_service from app.dependencies import get_document_service
from app.document_service import DocumentNotFoundError from app.document_service import DocumentNotFoundError
@ -1313,31 +1314,23 @@ def test_list_documents_returns_folders_and_files(client):
- [ ] **Step 2: Run test to verify it fails** - [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && pytest tests/test_routes_documents.py::test_list_documents_returns_folders_and_files -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_list_documents_returns_folders_and_files -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.routes'` Expected: FAIL with `ModuleNotFoundError: No module named 'app.routers'`
- [ ] **Step 3: Create `backend/app/routes/__init__.py` and write `documents.py` with `GET /api/documents`** - [ ] **Step 3: Create `backend/app/dependencies.py`, `backend/app/routers/__init__.py`, and write `documents.py` with `GET /api/documents`**
```python ```python
# backend/app/routes/__init__.py # backend/app/dependencies.py
```
```python
# backend/app/routes/documents.py
from functools import lru_cache from functools import lru_cache
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from minio import Minio
import meilisearch import meilisearch
from pydantic import BaseModel from minio import Minio
from app.config import settings from app.config import settings
from app.document_service import DocumentNotFoundError, DocumentService from app.document_service import DocumentService
from app.minio_client import MinioClient from app.minio_client import MinioClient
from app.search_client import SearchClient from app.search_client import SearchClient
router = APIRouter()
@lru_cache @lru_cache
def get_document_service() -> DocumentService: def get_document_service() -> DocumentService:
@ -1353,6 +1346,27 @@ def get_document_service() -> DocumentService:
return DocumentService(minio_client, search_client) return DocumentService(minio_client, search_client)
@lru_cache
def get_search_client() -> SearchClient:
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
return SearchClient(meili_sdk, settings.meilisearch_index)
```
```python
# backend/app/routers/__init__.py
```
```python
# backend/app/routers/documents.py
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import BaseModel
from app.dependencies import get_document_service
from app.document_service import DocumentNotFoundError, DocumentService
router = APIRouter()
class SaveDocumentRequest(BaseModel): class SaveDocumentRequest(BaseModel):
content: str content: str
contentType: str contentType: str
@ -1365,7 +1379,7 @@ def list_documents(path: str = "", service: DocumentService = Depends(get_docume
- [ ] **Step 4: Run test to verify it passes** - [ ] **Step 4: Run test to verify it passes**
Run: `cd backend && pytest tests/test_routes_documents.py::test_list_documents_returns_folders_and_files -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_list_documents_returns_folders_and_files -v`
Expected: PASS Expected: PASS
- [ ] **Step 5: Write the failing tests for `GET /api/documents/{key}` (found and 404)** - [ ] **Step 5: Write the failing tests for `GET /api/documents/{key}` (found and 404)**
@ -1384,13 +1398,13 @@ def test_get_document_returns_404_for_missing_key(client):
- [ ] **Step 6: Run tests to verify they fail** - [ ] **Step 6: Run tests to verify they fail**
Run: `cd backend && pytest tests/test_routes_documents.py::test_get_document_returns_content tests/test_routes_documents.py::test_get_document_returns_404_for_missing_key -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_get_document_returns_content tests/test_routers_documents.py::test_get_document_returns_404_for_missing_key -v`
Expected: FAIL with `404 Not Found` (route not registered) Expected: FAIL with `404 Not Found` (route not registered)
- [ ] **Step 7: Add `GET /api/documents/{key:path}`** - [ ] **Step 7: Add `GET /api/documents/{key:path}`**
```python ```python
# backend/app/routes/documents.py (add below list_documents) # backend/app/routers/documents.py (add below list_documents)
@router.get("/api/documents/{key:path}") @router.get("/api/documents/{key:path}")
def get_document(key: str, service: DocumentService = Depends(get_document_service)): def get_document(key: str, service: DocumentService = Depends(get_document_service)):
try: try:
@ -1401,7 +1415,7 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi
- [ ] **Step 8: Run tests to verify they pass** - [ ] **Step 8: Run tests to verify they pass**
Run: `cd backend && pytest tests/test_routes_documents.py::test_get_document_returns_content tests/test_routes_documents.py::test_get_document_returns_404_for_missing_key -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_get_document_returns_content tests/test_routers_documents.py::test_get_document_returns_404_for_missing_key -v`
Expected: PASS Expected: PASS
- [ ] **Step 9: Write the failing test for `PUT /api/documents/{key}`** - [ ] **Step 9: Write the failing test for `PUT /api/documents/{key}`**
@ -1420,13 +1434,13 @@ def test_put_document_saves_content(client):
- [ ] **Step 10: Run test to verify it fails** - [ ] **Step 10: Run test to verify it fails**
Run: `cd backend && pytest tests/test_routes_documents.py::test_put_document_saves_content -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_put_document_saves_content -v`
Expected: FAIL with `405 Method Not Allowed` Expected: FAIL with `405 Method Not Allowed`
- [ ] **Step 11: Add `PUT /api/documents/{key:path}`** - [ ] **Step 11: Add `PUT /api/documents/{key:path}`**
```python ```python
# backend/app/routes/documents.py (add below get_document) # backend/app/routers/documents.py (add below get_document)
@router.put("/api/documents/{key:path}") @router.put("/api/documents/{key:path}")
def save_document( def save_document(
key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service) key: str, body: SaveDocumentRequest, service: DocumentService = Depends(get_document_service)
@ -1436,7 +1450,7 @@ def save_document(
- [ ] **Step 12: Run test to verify it passes** - [ ] **Step 12: Run test to verify it passes**
Run: `cd backend && pytest tests/test_routes_documents.py::test_put_document_saves_content -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_put_document_saves_content -v`
Expected: PASS Expected: PASS
- [ ] **Step 13: Write the failing test for `DELETE /api/documents/{key}`** - [ ] **Step 13: Write the failing test for `DELETE /api/documents/{key}`**
@ -1451,13 +1465,13 @@ def test_delete_document_returns_204(client):
- [ ] **Step 14: Run test to verify it fails** - [ ] **Step 14: Run test to verify it fails**
Run: `cd backend && pytest tests/test_routes_documents.py::test_delete_document_returns_204 -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_delete_document_returns_204 -v`
Expected: FAIL with `405 Method Not Allowed` Expected: FAIL with `405 Method Not Allowed`
- [ ] **Step 15: Add `DELETE /api/documents/{key:path}`** - [ ] **Step 15: Add `DELETE /api/documents/{key:path}`**
```python ```python
# backend/app/routes/documents.py (add below save_document) # backend/app/routers/documents.py (add below save_document)
@router.delete("/api/documents/{key:path}", status_code=204) @router.delete("/api/documents/{key:path}", status_code=204)
def delete_document(key: str, service: DocumentService = Depends(get_document_service)): def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
service.delete_document(key) service.delete_document(key)
@ -1466,7 +1480,7 @@ def delete_document(key: str, service: DocumentService = Depends(get_document_se
- [ ] **Step 16: Run test to verify it passes** - [ ] **Step 16: Run test to verify it passes**
Run: `cd backend && pytest tests/test_routes_documents.py::test_delete_document_returns_204 -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_delete_document_returns_204 -v`
Expected: PASS Expected: PASS
- [ ] **Step 17: Write the failing test for `POST /api/upload`** - [ ] **Step 17: Write the failing test for `POST /api/upload`**
@ -1487,13 +1501,13 @@ def test_upload_document_returns_201_with_metadata(client):
- [ ] **Step 18: Run test to verify it fails** - [ ] **Step 18: Run test to verify it fails**
Run: `cd backend && pytest tests/test_routes_documents.py::test_upload_document_returns_201_with_metadata -v` Run: `cd backend && pytest tests/test_routers_documents.py::test_upload_document_returns_201_with_metadata -v`
Expected: FAIL with `404 Not Found` Expected: FAIL with `404 Not Found`
- [ ] **Step 19: Add `POST /api/upload`** - [ ] **Step 19: Add `POST /api/upload`**
```python ```python
# backend/app/routes/documents.py (add below delete_document) # backend/app/routers/documents.py (add below delete_document)
@router.post("/api/upload", status_code=201) @router.post("/api/upload", status_code=201)
async def upload_document( async def upload_document(
file: UploadFile = File(...), file: UploadFile = File(...),
@ -1508,37 +1522,38 @@ async def upload_document(
- [ ] **Step 20: Run full test file to verify everything passes** - [ ] **Step 20: Run full test file to verify everything passes**
Run: `cd backend && pytest tests/test_routes_documents.py -v` Run: `cd backend && pytest tests/test_routers_documents.py -v`
Expected: PASS (all 6 tests) — note: at this point `app.main` does not yet include `documents.router`, so these tests will actually fail with 404s until Task 8 wires the router into `app`. Proceed to Task 8 before considering this task fully green end-to-end; the route functions themselves are complete and unit-correct. Expected: PASS (all 6 tests) — note: at this point `app.main` does not yet include `documents.router`, so these tests will actually fail with 404s until Task 8 wires the router into `app`. Proceed to Task 8 before considering this task fully green end-to-end; the route functions themselves are complete and unit-correct.
- [ ] **Step 21: Commit** - [ ] **Step 21: Commit**
```bash ```bash
git add backend/app/routes/__init__.py backend/app/routes/documents.py backend/tests/test_routes_documents.py git add backend/app/dependencies.py backend/app/routers/__init__.py backend/app/routers/documents.py backend/tests/test_routers_documents.py
git commit -m "feat: add documents routes for list/get/save/delete/upload" git commit -m "feat: add documents router for list/get/save/delete/upload"
``` ```
--- ---
### Task 7: Search Route (`routes/search.py`) ### Task 7: Search Router (`routers/search.py`)
**Files:** **Files:**
- Create: `backend/app/routes/search.py` - Modify: `backend/app/dependencies.py`
- Test: `backend/tests/test_routes_search.py` - Create: `backend/app/routers/search.py`
- Test: `backend/tests/test_routers_search.py`
**Interfaces:** **Interfaces:**
- Consumes: `SearchClient.search` (Task 4), `app.config.settings` (Task 1) - Consumes: `SearchClient.search` (Task 4), `app.config.settings` (Task 1), `app.dependencies.get_search_client` (Task 6, already defined)
- Produces: `router` (APIRouter with `GET /api/search`), `get_search_client()` dependency provider both consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`. - Produces: `router` (APIRouter with `GET /api/search`) — consumed by `main.py` in Task 8 and by route/smoke tests via `app.dependency_overrides`.
- [ ] **Step 1: Write the failing test for `GET /api/search` with matches** - [ ] **Step 1: Write the failing test for `GET /api/search` with matches**
```python ```python
# backend/tests/test_routes_search.py # backend/tests/test_routers_search.py
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
from app.routes.search import get_search_client from app.dependencies import get_search_client
class FakeSearchClient: class FakeSearchClient:
@ -1579,38 +1594,31 @@ def test_search_returns_matching_results(client):
- [ ] **Step 2: Run test to verify it fails** - [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && pytest tests/test_routes_search.py::test_search_returns_matching_results -v` Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_matching_results -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.routes.search'` Expected: FAIL with `ModuleNotFoundError: No module named 'app.routers.search'`
- [ ] **Step 3: Write `search.py` with `GET /api/search`** - [ ] **Step 3: Write `search.py` with `GET /api/search`, reusing `get_search_client` from `dependencies.py`**
```python ```python
# backend/app/routes/search.py # backend/app/routers/search.py
from functools import lru_cache
import meilisearch
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.config import settings from app.dependencies import get_search_client
from app.search_client import SearchClient from app.search_client import SearchClient
router = APIRouter() router = APIRouter()
@lru_cache
def get_search_client() -> SearchClient:
meili_sdk = meilisearch.Client(settings.meilisearch_host, settings.meilisearch_api_key)
return SearchClient(meili_sdk, settings.meilisearch_index)
@router.get("/api/search") @router.get("/api/search")
def search(q: str = "", client: SearchClient = Depends(get_search_client)): def search(q: str = "", client: SearchClient = Depends(get_search_client)):
return {"results": client.search(q)} return {"results": client.search(q)}
``` ```
Note: `get_search_client` was already added to `backend/app/dependencies.py` in Task 6's Step 3. This task only adds the router that consumes it.
- [ ] **Step 4: Run test to verify it passes** - [ ] **Step 4: Run test to verify it passes**
Run: `cd backend && pytest tests/test_routes_search.py::test_search_returns_matching_results -v` Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_matching_results -v`
Expected: PASS — note: this test will actually return 404 until Task 8 wires `search.router` into `app.main`; the route logic itself is complete at this point. Expected: PASS — note: this test will actually return 404 until Task 8 wires `search.router` into `app.main`; the route logic itself is complete at this point.
- [ ] **Step 5: Write the failing test for `GET /api/search` with no matches** - [ ] **Step 5: Write the failing test for `GET /api/search` with no matches**
@ -1624,7 +1632,7 @@ def test_search_returns_empty_results_for_no_match(client):
- [ ] **Step 6: Run test (already satisfied by Step 3's implementation once wired in Task 8)** - [ ] **Step 6: Run test (already satisfied by Step 3's implementation once wired in Task 8)**
Run: `cd backend && pytest tests/test_routes_search.py::test_search_returns_empty_results_for_no_match -v` Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_empty_results_for_no_match -v`
Expected: PASS after Task 8 wires the router Expected: PASS after Task 8 wires the router
- [ ] **Step 7: Write the failing test asserting the query string is forwarded** - [ ] **Step 7: Write the failing test asserting the query string is forwarded**
@ -1637,13 +1645,13 @@ def test_search_passes_query_string_to_search_client(client):
- [ ] **Step 8: Run test (already satisfied by Step 3's implementation once wired in Task 8)** - [ ] **Step 8: Run test (already satisfied by Step 3's implementation once wired in Task 8)**
Run: `cd backend && pytest tests/test_routes_search.py::test_search_passes_query_string_to_search_client -v` Run: `cd backend && pytest tests/test_routers_search.py::test_search_passes_query_string_to_search_client -v`
Expected: PASS after Task 8 wires the router Expected: PASS after Task 8 wires the router
- [ ] **Step 9: Commit** - [ ] **Step 9: Commit**
```bash ```bash
git add backend/app/routes/search.py backend/tests/test_routes_search.py git add backend/app/routers/search.py backend/tests/test_routers_search.py
git commit -m "feat: add search route forwarding queries to SearchClient" git commit -m "feat: add search route forwarding queries to SearchClient"
``` ```
@ -1656,7 +1664,7 @@ git commit -m "feat: add search route forwarding queries to SearchClient"
- Edit: `backend/tests/test_main.py` - Edit: `backend/tests/test_main.py`
**Interfaces:** **Interfaces:**
- Consumes: `documents.router`, `documents.get_document_service` (Task 6); `search.router`, `search.get_search_client` (Task 7); `DocumentNotFoundError` (Task 5) - Consumes: `documents.router` (Task 6); `search.router` (Task 7); `app.dependencies.get_document_service`, `app.dependencies.get_search_client` (Task 6); `DocumentNotFoundError` (Task 5)
- Produces: fully wired `app.main.app` — the production FastAPI entrypoint served by `uvicorn app.main:app` (per `Dockerfile` from Task 1) and the object the frontend's API client (built separately) will call against. - Produces: fully wired `app.main.app` — the production FastAPI entrypoint served by `uvicorn app.main:app` (per `Dockerfile` from Task 1) and the object the frontend's API client (built separately) will call against.
- [ ] **Step 1: Write the failing end-to-end smoke test** - [ ] **Step 1: Write the failing end-to-end smoke test**
@ -1664,8 +1672,7 @@ git commit -m "feat: add search route forwarding queries to SearchClient"
```python ```python
# backend/tests/test_main.py (append to existing file from Task 1) # backend/tests/test_main.py (append to existing file from Task 1)
from app.document_service import DocumentNotFoundError from app.document_service import DocumentNotFoundError
from app.routes.documents import get_document_service from app.dependencies import get_document_service, get_search_client
from app.routes.search import get_search_client
class FakeDocumentService: class FakeDocumentService:
@ -1765,7 +1772,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.routes import documents, search from app.routers import documents, search
app = FastAPI(title="DocHub API") app = FastAPI(title="DocHub API")
@ -1789,7 +1796,7 @@ def health():
- [ ] **Step 4: Run the full backend test suite to verify everything passes** - [ ] **Step 4: Run the full backend test suite to verify everything passes**
Run: `cd backend && pytest tests/ -v` Run: `cd backend && pytest tests/ -v`
Expected: PASS (all tests across `test_main.py`, `test_text_extract.py`, `test_minio_client.py`, `test_search_client.py`, `test_document_service.py`, `test_routes_documents.py`, `test_routes_search.py`) Expected: PASS (all tests across `test_main.py`, `test_text_extract.py`, `test_minio_client.py`, `test_search_client.py`, `test_document_service.py`, `test_routers_documents.py`, `test_routers_search.py`)
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**