From 954f7ac367346cc7f319ba38996b2ac7de4abb35 Mon Sep 17 00:00:00 2001 From: Tianyang Date: Sat, 1 Aug 2026 02:01:28 +0800 Subject: [PATCH] Restructure backend plan to FastAPI's official routers/ + dependencies.py layout --- .../plans/2026-08-01-dochub-implementation.md | 143 +++++++++--------- 1 file changed, 75 insertions(+), 68 deletions(-) diff --git a/docs/superpowers/plans/2026-08-01-dochub-implementation.md b/docs/superpowers/plans/2026-08-01-dochub-implementation.md index 9e05bae..cade9b1 100644 --- a/docs/superpowers/plans/2026-08-01-dochub-implementation.md +++ b/docs/superpowers/plans/2026-08-01-dochub-implementation.md @@ -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**