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
57 changes: 44 additions & 13 deletions apps/admin-ui/src/app/corpora/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
TableSkeleton,
Textarea,
} from "@/components/ui/primitives";
import { fetchCorpora } from "@/lib/api";
import { createCorpus, deleteCorpus, fetchCorpora } from "@/lib/api";
import { GATEWAY_URL } from "@/lib/config";
import { CORPORA } from "@/lib/mock";
import { absTime, relTime } from "@/lib/time";
import type { Corpus } from "@/lib/types";
Expand Down Expand Up @@ -188,9 +189,35 @@ export default function CorporaPage() {

{creating && (
<NewCorpusDialog
tenantId={tenant.id}
onClose={() => setCreating(false)}
onCreate={(corpus) => {
onCreate={async ({ display_name, description, embedding_model, embedding_dimension }) => {
let corpus: Corpus;
if (GATEWAY_URL && source === "live") {
try {
corpus = await createCorpus(tenant.id, {
display_name,
description,
embedding_model,
embedding_dimension,
});
} catch {
toast({ title: "Failed to create corpus", variant: "error" });
return;
}
} else {
corpus = {
id: "corpus_" + Math.random().toString(16).slice(2, 14),
tenant_id: tenant.id,
display_name,
description,
embedding_model,
embedding_dimension,
document_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {},
};
}
setRows((prev) => [corpus, ...prev]);
setCreating(false);
toast({ title: "Corpus created", description: corpus.display_name, variant: "success" });
Expand All @@ -214,8 +241,15 @@ export default function CorporaPage() {
</Button>
<Button
variant="destructive"
onClick={() => {
onClick={async () => {
if (deleting) {
if (GATEWAY_URL && source === "live") {
try {
await deleteCorpus(tenant.id, deleting.id);
} catch {
/* fall through to local removal */
}
}
setRows((prev) => prev.filter((c) => c.id !== deleting.id));
toast({ title: "Corpus deleted", description: deleting.display_name, variant: "error" });
}
Expand All @@ -231,13 +265,16 @@ export default function CorporaPage() {
}

function NewCorpusDialog({
tenantId,
onClose,
onCreate,
}: {
tenantId: string;
onClose: () => void;
onCreate: (c: Corpus) => void;
onCreate: (v: {
display_name: string;
description: string;
embedding_model: string;
embedding_dimension: number;
}) => void;
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
Expand Down Expand Up @@ -276,16 +313,10 @@ function NewCorpusDialog({
disabled={!name.trim()}
onClick={() =>
onCreate({
id: "corpus_" + Math.random().toString(16).slice(2, 14),
tenant_id: tenantId,
display_name: name.trim(),
description: description.trim(),
embedding_model: model,
embedding_dimension: dim,
document_count: 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
metadata: {},
})
}
>
Expand Down
19 changes: 19 additions & 0 deletions apps/admin-ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ export async function fetchCorpora(tenantId: string): Promise<Corpus[]> {
return data.corpora;
}

export async function createCorpus(
tenantId: string,
body: {
display_name: string;
description: string;
embedding_model: string;
embedding_dimension: number;
},
): Promise<Corpus> {
return request<Corpus>(tenantId, "/v1/corpora", {
method: "POST",
body: JSON.stringify(body),
});
}

export async function deleteCorpus(tenantId: string, id: string): Promise<void> {
await request<void>(tenantId, `/v1/corpora/${id}`, { method: "DELETE" });
}

export async function fetchWebhooks(tenantId: string): Promise<WebhookSubscription[]> {
const data = await request<{ subscriptions: WebhookSubscription[]; total: number }>(
tenantId,
Expand Down
102 changes: 91 additions & 11 deletions apps/gateway/src/rag_gateway/corpora.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,44 @@
"""``/v1/corpora`` read-only CRUD (Step 3.1).
"""``/v1/corpora`` CRUD (read in Step 3.1, admin writes in Step 3.10).

The gateway exposes corpus metadata as a discoverability surface for
SDK consumers + admin UIs: clients hitting ``/v1/query`` need a way
to enumerate the corpora available to the calling tenant, and to
verify a given ``corpus_id`` is real (and visible) before making a
query.

v0 is **read-only** — list + get. Create / update / delete belong
with the admin UI surface in Step 3.10 (and the Postgres-backed
:class:`CorpusStore` impl that lands with the corpus router in
Step 3.5). Read-only here keeps the SPI small while giving SDKs
something to discover against.
Read (``GET`` list + get) is the discovery surface; ``POST /v1/corpora``
and ``DELETE /v1/corpora/{id}`` (Step 3.10) are the admin write surface
behind the console's corpus-management UI. The id is minted server-side
and the tenant is taken from ``ctx``, so a caller can neither choose a
corpus id nor write into (or delete from) another tenant.

Tenant isolation is enforced at the SPI boundary:
:meth:`CorpusStore.get` returns ``None`` for cross-tenant lookups
rather than raising, so callers can't probe for other tenants'
corpora. This route surfaces ``None`` as ``404 Not Found``.
:meth:`CorpusStore.get` / :meth:`CorpusStore.delete` treat a
cross-tenant id as not-visible rather than raising, so callers can't
probe for other tenants' corpora. These routes surface that as
``404 Not Found``.
"""

from __future__ import annotations

from fastapi import APIRouter, Request
import uuid

from fastapi import APIRouter, Request, Response
from fastapi.responses import JSONResponse
from rag_core import get_logger
from rag_core.gateway_types import Corpus, CorpusList, GatewayError
from rag_core.gateway_types import Corpus, CorpusList, CreateCorpusRequest, GatewayError
from rag_core.types import CorpusId, RequestContext

from rag_gateway.query import _require_ctx_for_handler

_log = get_logger(__name__)


def _new_corpus_id() -> str:
"""Mint a unique corpus id — server-assigned so callers can't choose one."""
return f"corpus_{uuid.uuid4().hex}"


def make_corpora_router() -> APIRouter:
"""Build the APIRouter for ``GET /v1/corpora`` + ``GET /v1/corpora/{id}``."""
router = APIRouter(tags=["corpora"])
Expand All @@ -55,6 +63,45 @@ async def list_corpora(request: Request) -> CorpusList:
corpora = await store.list(ctx)
return CorpusList(corpora=corpora, total=len(corpora))

@router.post(
"/v1/corpora",
response_model=Corpus,
status_code=201,
responses={
401: {"model": GatewayError, "description": "Missing or invalid auth"},
},
summary="Create a corpus for the calling tenant",
)
async def create_corpus(request: Request, body: CreateCorpusRequest) -> Corpus:
"""Create a corpus owned by the calling tenant.

The id is minted server-side (``corpus_…``) and the tenant comes
from ``ctx`` — the body carries only display fields + embedding
metadata, so a caller can neither pick an id nor create into
another tenant. ``document_count`` starts at 0 and is maintained
by the ingest path.
"""
ctx = _require_ctx_for_handler(request)
store = request.app.state.corpus_store
corpus = Corpus(
id=CorpusId(_new_corpus_id()),
tenant_id=ctx.tenant_id,
display_name=body.display_name,
description=body.description,
embedding_model=body.embedding_model,
embedding_dimension=body.embedding_dimension,
metadata=body.metadata,
)
created: Corpus = await store.create(ctx, corpus)
_log.info(
"corpus.created",
extra={
"rag_tenant_id": str(ctx.tenant_id),
"rag_corpus_id": str(created.id),
},
)
return created

@router.get(
"/v1/corpora/{corpus_id}",
response_model=Corpus,
Expand Down Expand Up @@ -85,6 +132,39 @@ async def get_corpus(
)
return corpus

@router.delete(
"/v1/corpora/{corpus_id}",
status_code=204,
responses={
401: {"model": GatewayError, "description": "Missing or invalid auth"},
404: {"model": GatewayError, "description": "Corpus not found / not visible"},
},
summary="Delete a corpus",
)
async def delete_corpus(request: Request, corpus_id: str) -> Response:
"""Delete a corpus owned by the calling tenant.

Cross-tenant / unknown ids return 404 — the same invisibility
rule as ``GET`` — so a caller can't probe for or delete another
tenant's corpora.
"""
ctx = _require_ctx_for_handler(request)
store = request.app.state.corpus_store
deleted = await store.delete(ctx, CorpusId(corpus_id))
if not deleted:
return JSONResponse(
status_code=404,
content=_not_found_body(corpus_id, ctx),
)
_log.info(
"corpus.deleted",
extra={
"rag_tenant_id": str(ctx.tenant_id),
"rag_corpus_id": corpus_id,
},
)
return Response(status_code=204)

return router


Expand Down
99 changes: 99 additions & 0 deletions apps/gateway/tests/test_corpora.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Gateway corpus surface — list / get (read) + create / delete (admin writes).

The read side landed in Step 3.1; ``POST /v1/corpora`` + ``DELETE
/v1/corpora/{id}`` are the Step 3.10 admin write surface. These tests pin the
contract the admin console relies on: server-minted ids, tenant scoping (a
tenant can neither see nor delete another tenant's corpus), and the
create→list→delete→404 lifecycle.
"""

from __future__ import annotations

from typing import Any

from fastapi.testclient import TestClient
from rag_gateway import build_app

HEADERS = {"x-tenant-id": "acme", "x-principal-id": "svc"}
OTHER = {"x-tenant-id": "globex", "x-principal-id": "svc"}


def _client() -> TestClient:
return TestClient(build_app())


def _create(
client: TestClient, headers: dict[str, str] = HEADERS, **body: Any
) -> dict[str, Any]:
payload = {"display_name": "Support KB", **body}
resp = client.post("/v1/corpora", json=payload, headers=headers)
assert resp.status_code == 201, resp.text
return resp.json()


def test_create_mints_id_and_scopes_tenant() -> None:
client = _client()
created = _create(
client,
description="Tickets",
embedding_model="text-embedding-3-small",
embedding_dimension=1536,
)
assert created["id"].startswith("corpus_")
assert created["tenant_id"] == "acme" # taken from ctx, not the body
assert created["display_name"] == "Support KB"
assert created["description"] == "Tickets"
assert created["embedding_dimension"] == 1536
assert created["document_count"] == 0 # ingest maintains this, not create


def test_created_corpus_is_listed_and_gettable() -> None:
client = _client()
created = _create(client)
listed = client.get("/v1/corpora", headers=HEADERS).json()
assert listed["total"] == 1
assert listed["corpora"][0]["id"] == created["id"]
got = client.get(f"/v1/corpora/{created['id']}", headers=HEADERS).json()
assert got["id"] == created["id"]


def test_create_rejects_blank_display_name() -> None:
client = _client()
resp = client.post("/v1/corpora", json={"display_name": ""}, headers=HEADERS)
assert resp.status_code == 422 # pydantic min_length


def test_create_requires_auth() -> None:
client = _client()
resp = client.post("/v1/corpora", json={"display_name": "x"})
assert resp.status_code == 401


def test_delete_removes_then_404s() -> None:
client = _client()
created = _create(client)
assert (
client.delete(f"/v1/corpora/{created['id']}", headers=HEADERS).status_code == 204
)
# gone from the list...
assert client.get("/v1/corpora", headers=HEADERS).json()["total"] == 0
# ...and a second delete is a 404 with the canonical error code.
resp = client.delete(f"/v1/corpora/{created['id']}", headers=HEADERS)
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "corpus_not_found"


def test_delete_unknown_returns_404() -> None:
client = _client()
assert client.delete("/v1/corpora/corpus_nope", headers=HEADERS).status_code == 404


def test_tenant_isolation_on_get_and_delete() -> None:
client = _client()
created = _create(client) # owned by acme
# globex can neither read it...
assert client.get(f"/v1/corpora/{created['id']}", headers=OTHER).status_code == 404
# ...nor delete it...
assert client.delete(f"/v1/corpora/{created['id']}", headers=OTHER).status_code == 404
# ...and acme still has it.
assert client.get(f"/v1/corpora/{created['id']}", headers=HEADERS).status_code == 200
Loading
Loading