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:**
- 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`**
@ -901,7 +901,7 @@ git commit -m "feat: add SearchClient wrapper for index/delete/search operations
**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)
- 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`**
@ -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:**
- Create: `backend/app/routes/__init__.py`
- Create: `backend/app/routes/documents.py`
- Test: `backend/tests/test_routes_documents.py`
- Create: `backend/app/dependencies.py`
- Create: `backend/app/routers/__init__.py`
- Create: `backend/app/routers/documents.py`
- Test: `backend/tests/test_routers_documents.py`
**Interfaces:**
- 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`**
```python
# backend/tests/test_routes_documents.py
# backend/tests/test_routers_documents.py
import pytest
from fastapi.testclient import TestClient
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
@ -1313,31 +1314,23 @@ def test_list_documents_returns_folders_and_files(client):
- [ ] **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`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.routes'`
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.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
# backend/app/routes/__init__.py
```
```python
# backend/app/routes/documents.py
# backend/app/dependencies.py
from functools import lru_cache
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from minio import Minio
import meilisearch
from pydantic import BaseModel
from minio import Minio
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.search_client import SearchClient
router = APIRouter()
@lru_cache
def get_document_service() -> DocumentService:
@ -1353,6 +1346,27 @@ def get_document_service() -> DocumentService:
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):
content: 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**
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
- [ ] **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**
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)
- [ ] **Step 7: Add `GET /api/documents/{key:path}`**
```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}")
def get_document(key: str, service: DocumentService = Depends(get_document_service)):
try:
@ -1401,7 +1415,7 @@ def get_document(key: str, service: DocumentService = Depends(get_document_servi
- [ ] **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
- [ ] **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**
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`
- [ ] **Step 11: Add `PUT /api/documents/{key:path}`**
```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}")
def save_document(
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**
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
- [ ] **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**
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`
- [ ] **Step 15: Add `DELETE /api/documents/{key:path}`**
```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)
def delete_document(key: str, service: DocumentService = Depends(get_document_service)):
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**
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
- [ ] **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**
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`
- [ ] **Step 19: Add `POST /api/upload`**
```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)
async def upload_document(
file: UploadFile = File(...),
@ -1508,37 +1522,38 @@ async def upload_document(
- [ ] **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.
- [ ] **Step 21: Commit**
```bash
git add backend/app/routes/__init__.py backend/app/routes/documents.py backend/tests/test_routes_documents.py
git commit -m "feat: add documents routes for list/get/save/delete/upload"
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 router for list/get/save/delete/upload"
```
---
### Task 7: Search Route (`routes/search.py`)
### Task 7: Search Router (`routers/search.py`)
**Files:**
- Create: `backend/app/routes/search.py`
- Test: `backend/tests/test_routes_search.py`
- Modify: `backend/app/dependencies.py`
- Create: `backend/app/routers/search.py`
- Test: `backend/tests/test_routers_search.py`
**Interfaces:**
- Consumes: `SearchClient.search` (Task 4), `app.config.settings` (Task 1)
- 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`.
- 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`) — 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**
```python
# backend/tests/test_routes_search.py
# backend/tests/test_routers_search.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.routes.search import get_search_client
from app.dependencies import get_search_client
class FakeSearchClient:
@ -1579,38 +1594,31 @@ def test_search_returns_matching_results(client):
- [ ] **Step 2: Run test to verify it fails**
Run: `cd backend && pytest tests/test_routes_search.py::test_search_returns_matching_results -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'app.routes.search'`
Run: `cd backend && pytest tests/test_routers_search.py::test_search_returns_matching_results -v`
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
# backend/app/routes/search.py
from functools import lru_cache
import meilisearch
# backend/app/routers/search.py
from fastapi import APIRouter, Depends
from app.config import settings
from app.dependencies import get_search_client
from app.search_client import SearchClient
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")
def search(q: str = "", client: SearchClient = Depends(get_search_client)):
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**
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.
- [ ] **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)**
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
- [ ] **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)**
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
- [ ] **Step 9: Commit**
```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"
```
@ -1656,7 +1664,7 @@ git commit -m "feat: add search route forwarding queries to SearchClient"
- Edit: `backend/tests/test_main.py`
**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.
- [ ] **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
# backend/tests/test_main.py (append to existing file from Task 1)
from app.document_service import DocumentNotFoundError
from app.routes.documents import get_document_service
from app.routes.search import get_search_client
from app.dependencies import get_document_service, get_search_client
class FakeDocumentService:
@ -1765,7 +1772,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routes import documents, search
from app.routers import documents, search
app = FastAPI(title="DocHub API")
@ -1789,7 +1796,7 @@ def health():
- [ ] **Step 4: Run the full backend test suite to verify everything passes**
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**