feat(#251): /api/stores router + wiring + schemas Pydantic + tests API

- Schemas camelCase : StoreInfoResponse, StoreResponse, StoreCreate/UpdateRequest, StoreDocEntryResponse
- Router /api/stores : GET (list), POST (create 201), GET/{slug}, PATCH/{slug}, DELETE/{slug} (204)
- Endpoints documents-in-store : GET /{slug}/documents, DELETE /{slug}/documents/{docId}
- Mapping erreurs StoreServiceError → HTTPException via http_status hint
- StoreService étendu : list_documents/remove_document avec injection optionnelle de document_repo (filename humain)
- Wiring lifespan : SqliteStoreRepository + SqliteDocumentStoreLinkRepository + StoreService sur app.state
- include_router(stores_router) après documents/analyses
- Tests API : 18 cas couvrant 200/201/204/404/409/422 + camelCase + cycle complet
This commit is contained in:
Pier-Jean Malandrino 2026-05-04 12:22:53 +02:00
parent 9d2e9ab0f4
commit 245ef40a24
5 changed files with 492 additions and 0 deletions

View file

@ -233,3 +233,63 @@ class SearchResponse(_CamelModel):
results: list[SearchResultItem]
total: int
query: str
# ---------------------------------------------------------------------------
# Stores (#251)
# ---------------------------------------------------------------------------
class StoreInfoResponse(_CamelModel):
"""Read model for `GET /api/stores`."""
name: str
slug: str
type: str
embedder: str
is_default: bool
document_count: int
chunk_count: int
connected: bool
error_message: str | None = None
class StoreResponse(_CamelModel):
"""Detailed read model for `GET /api/stores/{slug}`."""
id: str
name: str
slug: str
kind: str
embedder: str
is_default: bool
config: dict
created_at: str | datetime
class StoreCreateRequest(_CamelModel):
name: str
slug: str
kind: str
embedder: str
config: dict = Field(default_factory=dict)
is_default: bool = False
class StoreUpdateRequest(_CamelModel):
"""Partial update — every field is optional. Use `slug` to rename."""
name: str | None = None
slug: str | None = None
kind: str | None = None
embedder: str | None = None
config: dict | None = None
is_default: bool | None = None
class StoreDocEntryResponse(_CamelModel):
doc_id: str
filename: str
state: str
chunk_count: int
pushed_at: str | None = None

View file

@ -0,0 +1,170 @@
"""Stores API router — CRUD on ingestion targets (#251)."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from api.schemas import (
StoreCreateRequest,
StoreDocEntryResponse,
StoreInfoResponse,
StoreResponse,
StoreUpdateRequest,
)
from domain.value_objects import StoreKind
from services.store_service import (
StoreService,
StoreServiceError,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/stores", tags=["stores"])
def _get_service(request: Request) -> StoreService:
return request.app.state.store_service
ServiceDep = Annotated[StoreService, Depends(_get_service)]
def _parse_kind(value: str) -> StoreKind:
try:
return StoreKind(value)
except ValueError as exc:
valid = ", ".join(k.value for k in StoreKind)
raise HTTPException(
status_code=422,
detail=f"Unknown store kind '{value}'. Valid kinds: {valid}.",
) from exc
def _store_to_response(store) -> StoreResponse:
return StoreResponse(
id=store.id,
name=store.name,
slug=store.slug,
kind=store.kind.value,
embedder=store.embedder,
is_default=store.is_default,
config=store.config,
created_at=str(store.created_at),
)
def _info_to_response(view) -> StoreInfoResponse:
return StoreInfoResponse(
name=view.name,
slug=view.slug,
type=view.kind,
embedder=view.embedder,
is_default=view.is_default,
document_count=view.document_count,
chunk_count=view.chunk_count,
connected=view.connected,
error_message=view.error_message,
)
def _doc_entry_to_response(entry) -> StoreDocEntryResponse:
return StoreDocEntryResponse(
doc_id=entry.doc_id,
filename=entry.filename,
state=entry.state,
chunk_count=entry.chunk_count,
pushed_at=entry.pushed_at,
)
@router.get("", response_model=list[StoreInfoResponse])
async def list_stores(service: ServiceDep) -> list[StoreInfoResponse]:
views = await service.list_stores()
return [_info_to_response(v) for v in views]
@router.post("", response_model=StoreResponse, status_code=201)
async def create_store(
payload: StoreCreateRequest,
service: ServiceDep,
) -> StoreResponse:
kind = _parse_kind(payload.kind)
try:
store = await service.create_store(
name=payload.name,
slug=payload.slug,
kind=kind,
embedder=payload.embedder,
config=payload.config,
is_default=payload.is_default,
)
except StoreServiceError as exc:
raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc
return _store_to_response(store)
@router.get("/{slug}", response_model=StoreResponse)
async def get_store(slug: str, service: ServiceDep) -> StoreResponse:
try:
store = await service.get_by_slug(slug)
except StoreServiceError as exc:
raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc
return _store_to_response(store)
@router.patch("/{slug}", response_model=StoreResponse)
async def update_store(
slug: str,
payload: StoreUpdateRequest,
service: ServiceDep,
) -> StoreResponse:
kind = _parse_kind(payload.kind) if payload.kind is not None else None
try:
store = await service.update_store(
slug,
name=payload.name,
new_slug=payload.slug,
kind=kind,
embedder=payload.embedder,
config=payload.config,
is_default=payload.is_default,
)
except StoreServiceError as exc:
raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc
return _store_to_response(store)
@router.delete("/{slug}", status_code=204)
async def delete_store(slug: str, service: ServiceDep) -> Response:
try:
await service.delete_store(slug)
except StoreServiceError as exc:
raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc
return Response(status_code=204)
@router.get("/{slug}/documents", response_model=list[StoreDocEntryResponse])
async def list_store_documents(
slug: str,
service: ServiceDep,
) -> list[StoreDocEntryResponse]:
try:
entries = await service.list_documents(slug)
except StoreServiceError as exc:
raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc
return [_doc_entry_to_response(e) for e in entries]
@router.delete("/{slug}/documents/{doc_id}", status_code=204)
async def remove_store_document(
slug: str,
doc_id: str,
service: ServiceDep,
) -> Response:
try:
await service.remove_document(slug, doc_id)
except StoreServiceError as exc:
raise HTTPException(status_code=exc.http_status, detail=str(exc)) from exc
return Response(status_code=204)

View file

@ -22,14 +22,18 @@ from api.analyses import router as analyses_router
from api.documents import router as documents_router
from api.ingestion import router as ingestion_router
from api.schemas import HealthResponse
from api.stores import router as stores_router
from infra.rate_limiter import RateLimiterMiddleware
from infra.settings import settings
from persistence.analysis_repo import SqliteAnalysisRepository
from persistence.database import get_connection, init_db
from persistence.document_repo import SqliteDocumentRepository
from persistence.document_store_link_repo import SqliteDocumentStoreLinkRepository
from persistence.store_repo import SqliteStoreRepository
from services.analysis_service import AnalysisConfig, AnalysisService
from services.document_service import DocumentConfig, DocumentService
from services.ingestion_service import IngestionConfig, IngestionService
from services.store_service import StoreService
logging.basicConfig(
level=logging.INFO,
@ -187,6 +191,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
document_repo, analysis_repo, neo4j_driver=app.state.neo4j
)
app.state.document_service = _build_document_service(document_repo, analysis_repo)
store_repo = SqliteStoreRepository()
link_repo = SqliteDocumentStoreLinkRepository()
app.state.store_repo = store_repo
app.state.document_store_link_repo = link_repo
app.state.store_service = StoreService(
store_repo=store_repo,
link_repo=link_repo,
document_repo=document_repo,
)
ingestion_service = _build_ingestion_service(neo4j_driver=app.state.neo4j)
app.state.ingestion_service = ingestion_service
if ingestion_service is not None:
@ -224,6 +237,7 @@ if settings.rate_limit_rpm > 0:
app.include_router(documents_router)
app.include_router(analyses_router)
app.include_router(stores_router)
# Graph view — mounted regardless; individual requests 503 if Neo4j is absent.
from api.graph import router as graph_router # noqa: E402

View file

@ -20,6 +20,7 @@ from domain.models import Store
from domain.value_objects import StoreKind
if TYPE_CHECKING:
from persistence.document_repo import SqliteDocumentRepository
from persistence.document_store_link_repo import SqliteDocumentStoreLinkRepository
from persistence.store_repo import SqliteStoreRepository
@ -67,6 +68,17 @@ class StoreInfoView:
error_message: str | None = None
@dataclass
class StoreDocEntryView:
"""Read model for `GET /api/stores/{slug}/documents`."""
doc_id: str
filename: str
state: str
chunk_count: int
pushed_at: str | None
def _validate_slug(slug: str) -> None:
if not slug or not _SLUG_PATTERN.match(slug):
raise StoreValidationError(
@ -91,9 +103,11 @@ class StoreService:
self,
store_repo: SqliteStoreRepository,
link_repo: SqliteDocumentStoreLinkRepository,
document_repo: SqliteDocumentRepository | None = None,
):
self._stores = store_repo
self._links = link_repo
self._documents = document_repo
async def list_stores(self) -> list[StoreInfoView]:
stores = await self._stores.find_all()
@ -217,6 +231,40 @@ class StoreService:
await self._stores.clear_default_except(store.id)
return store
async def list_documents(self, slug: str) -> list[StoreDocEntryView]:
store = await self.get_by_slug(slug)
links = await self._links.find_for_store(store.id)
if self._documents is None:
return [
StoreDocEntryView(
doc_id=link.document_id,
filename=link.document_id,
state=link.state.value,
chunk_count=0,
pushed_at=str(link.last_push_at) if link.last_push_at else None,
)
for link in links
]
entries: list[StoreDocEntryView] = []
for link in links:
doc = await self._documents.find_by_id(link.document_id)
entries.append(
StoreDocEntryView(
doc_id=link.document_id,
filename=doc.filename if doc else link.document_id,
state=link.state.value,
chunk_count=0,
pushed_at=str(link.last_push_at) if link.last_push_at else None,
)
)
return entries
async def remove_document(self, slug: str, doc_id: str) -> None:
store = await self.get_by_slug(slug)
removed = await self._links.delete(doc_id, store.id)
if not removed:
raise StoreNotFoundError(f"document '{doc_id}' is not linked to store '{slug}'")
async def delete_store(self, slug: str) -> None:
store = await self.get_by_slug(slug)
if store.slug == "default":

View file

@ -0,0 +1,200 @@
"""Tests for `/api/stores/*` HTTP endpoints (#251)."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from domain.models import Document, DocumentStoreLink
from domain.value_objects import DocumentStoreLinkState
from main import app
from persistence.database import init_db
from persistence.document_repo import SqliteDocumentRepository
from persistence.document_store_link_repo import SqliteDocumentStoreLinkRepository
from persistence.store_repo import SqliteStoreRepository
from services.store_service import StoreService
@pytest.fixture(autouse=True)
async def setup_db_and_state(monkeypatch, tmp_path):
db_path = str(tmp_path / "test.db")
monkeypatch.setattr("persistence.database.DB_PATH", db_path)
await init_db()
store_repo = SqliteStoreRepository()
link_repo = SqliteDocumentStoreLinkRepository()
document_repo = SqliteDocumentRepository()
original = getattr(app.state, "store_service", None)
app.state.store_service = StoreService(store_repo, link_repo, document_repo)
yield
app.state.store_service = original
@pytest.fixture
def client():
return TestClient(app, raise_server_exceptions=False)
VALID_OS = {
"name": "rh",
"slug": "rh",
"kind": "opensearch",
"embedder": "bge-m3",
"config": {"indexName": "rh"},
}
class TestList:
def test_list_returns_seeded_default(self, client):
resp = client.get("/api/stores")
assert resp.status_code == 200
slugs = [s["slug"] for s in resp.json()]
assert "default" in slugs
def test_list_uses_camel_case(self, client):
resp = client.get("/api/stores")
body = resp.json()[0]
assert "documentCount" in body
assert "isDefault" in body
class TestCreate:
def test_create_201(self, client):
resp = client.post("/api/stores", json=VALID_OS)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["slug"] == "rh"
assert body["isDefault"] is False
def test_create_duplicate_slug_409(self, client):
client.post("/api/stores", json=VALID_OS)
resp = client.post("/api/stores", json={**VALID_OS, "name": "rh-2"})
assert resp.status_code == 409
def test_create_invalid_kind_422(self, client):
resp = client.post("/api/stores", json={**VALID_OS, "kind": "pinecone"})
assert resp.status_code == 422
def test_create_missing_index_422(self, client):
resp = client.post("/api/stores", json={**VALID_OS, "config": {}})
assert resp.status_code == 422
def test_create_bad_slug_422(self, client):
resp = client.post("/api/stores", json={**VALID_OS, "slug": "RH Corp"})
assert resp.status_code == 422
class TestRead:
def test_get_by_slug(self, client):
client.post("/api/stores", json=VALID_OS)
resp = client.get("/api/stores/rh")
assert resp.status_code == 200
body = resp.json()
assert body["slug"] == "rh"
assert body["embedder"] == "bge-m3"
def test_get_unknown_404(self, client):
resp = client.get("/api/stores/missing")
assert resp.status_code == 404
class TestUpdate:
def test_patch_embedder(self, client):
client.post("/api/stores", json=VALID_OS)
resp = client.patch("/api/stores/rh", json={"embedder": "bge-large"})
assert resp.status_code == 200
assert resp.json()["embedder"] == "bge-large"
def test_patch_rename_slug(self, client):
client.post("/api/stores", json=VALID_OS)
resp = client.patch("/api/stores/rh", json={"slug": "rh-v2"})
assert resp.status_code == 200
assert resp.json()["slug"] == "rh-v2"
assert client.get("/api/stores/rh").status_code == 404
assert client.get("/api/stores/rh-v2").status_code == 200
def test_patch_unknown_404(self, client):
resp = client.patch("/api/stores/missing", json={"embedder": "x"})
assert resp.status_code == 404
class TestDelete:
def test_delete_204(self, client):
client.post("/api/stores", json=VALID_OS)
resp = client.delete("/api/stores/rh")
assert resp.status_code == 204
assert client.get("/api/stores/rh").status_code == 404
def test_delete_default_409(self, client):
resp = client.delete("/api/stores/default")
assert resp.status_code == 409
async def test_delete_with_links_409(self, client):
client.post("/api/stores", json=VALID_OS)
# Seed a link from outside (service-level access).
await SqliteDocumentRepository().insert(
Document(id="d-1", filename="t.pdf", storage_path="/tmp/t.pdf")
)
store = await SqliteStoreRepository().find_by_slug("rh")
assert store is not None
await SqliteDocumentStoreLinkRepository().upsert(
DocumentStoreLink(
id="l-1",
document_id="d-1",
store_id=store.id,
state=DocumentStoreLinkState.INGESTED,
)
)
resp = client.delete("/api/stores/rh")
assert resp.status_code == 409
class TestStoreDocuments:
async def test_list_documents_for_store(self, client):
client.post("/api/stores", json=VALID_OS)
await SqliteDocumentRepository().insert(
Document(id="d-1", filename="hr.pdf", storage_path="/tmp/hr.pdf")
)
store = await SqliteStoreRepository().find_by_slug("rh")
assert store is not None
await SqliteDocumentStoreLinkRepository().upsert(
DocumentStoreLink(
id="l-1",
document_id="d-1",
store_id=store.id,
state=DocumentStoreLinkState.INGESTED,
)
)
resp = client.get("/api/stores/rh/documents")
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
assert body[0]["docId"] == "d-1"
assert body[0]["filename"] == "hr.pdf"
async def test_remove_document_204(self, client):
client.post("/api/stores", json=VALID_OS)
await SqliteDocumentRepository().insert(
Document(id="d-1", filename="hr.pdf", storage_path="/tmp/hr.pdf")
)
store = await SqliteStoreRepository().find_by_slug("rh")
assert store is not None
await SqliteDocumentStoreLinkRepository().upsert(
DocumentStoreLink(
id="l-1",
document_id="d-1",
store_id=store.id,
state=DocumentStoreLinkState.INGESTED,
)
)
resp = client.delete("/api/stores/rh/documents/d-1")
assert resp.status_code == 204
# Now empty.
listing = client.get("/api/stores/rh/documents")
assert listing.json() == []
def test_remove_unknown_doc_404(self, client):
client.post("/api/stores", json=VALID_OS)
resp = client.delete("/api/stores/rh/documents/missing")
assert resp.status_code == 404