From 27061c30b036a88564d989cf6d920167c08b5f54 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 7 Jun 2026 17:35:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(slice3):=20collection=20list=20endpoint=20?= =?UTF-8?q?returns=20facets=20+=20honours=20filters=20(=C2=A722.4a)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api.py | 59 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/backend/app/api.py b/backend/app/api.py index 5cfe89d..22ddc81 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -41,6 +41,7 @@ from . import ( docs_specs, entry as entry_mod, cache, + facets, funder, health, notify, @@ -737,9 +738,33 @@ def make_router( raise HTTPException(404, "Not found") def _list_rfcs_for_collection( - collection_id: str, viewer, unreviewed: str | None + collection_id: str, viewer, unreviewed: str | None, + query_params=None, ) -> dict[str, Any]: viewer_id = viewer.user_id if viewer else None + # §22.4a SLICE-3: the collection's declared field schema drives the facet + # set (None when undeclared → no facets, INV-5). + col = collections_mod.get_collection(collection_id) + fields_schema = (col or {}).get("fields") or None + + # Parse + validate filter selections from the query string. Unknown + # field → 400 (§6.4). `unreviewed` keeps its existing meaning; an + # empty-valued selection is ignored, not an error (plan decision 6). + selections: dict[str, set[str]] = {} + only_malformed = False + if query_params is not None: + allowed = facets.allowed_filter_keys(fields_schema) + facet_names = {n for n, _ in facets.facet_fields(fields_schema)} + for key in query_params.keys(): + if key not in allowed: + raise HTTPException(400, f"unknown filter field {key!r}") + if (query_params.get("malformed") or "").lower() in ("1", "true", "yes"): + only_malformed = True + for name in facet_names: + vals = {v for v in query_params.getlist(name) if v != ""} + if vals: + selections[name] = vals + unreviewed_clause = "" if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"): unreviewed_clause = " AND unreviewed = 1 AND state = 'active'" @@ -747,6 +772,7 @@ def make_router( f""" SELECT slug, title, state, rfc_id, repo, owners_json, arbiters_json, tags_json, metadata_malformed, + meta_json, last_main_commit_at, last_entry_commit_at, updated_at FROM cached_rfcs WHERE state IN ('super-draft', 'active') @@ -764,8 +790,16 @@ def make_router( (viewer_id, collection_id), ) } - items = [ - { + + # Build entry dicts the facet helper understands (state + malformed + + # parsed meta), preserving SQL order. + entries = [] + for r in rows: + try: + meta = json.loads(r["meta_json"]) if r["meta_json"] else {} + except (TypeError, ValueError): + meta = {} + entries.append({ "slug": r["slug"], "title": r["title"], "state": r["state"], @@ -778,10 +812,13 @@ def make_router( "starred_by_me": r["slug"] in starred, "has_open_prs": False, "metadata_malformed": bool(r["metadata_malformed"]), - } - for r in rows - ] - return {"items": items} + "meta": meta, + }) + + filtered, facet_counts = facets.filter_and_count( + entries, fields_schema, selections, only_malformed=only_malformed + ) + return {"items": filtered, "facets": facet_counts} def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]: row = db.conn().execute( @@ -813,7 +850,9 @@ def make_router( auth.require_project_readable(viewer, project_id) # §22 S1: the project-scoped route serves the default collection. collection_id = collections_mod.default_collection_id(project_id) - return _list_rfcs_for_collection(collection_id, viewer, unreviewed) + return _list_rfcs_for_collection( + collection_id, viewer, unreviewed, query_params=request.query_params + ) @router.get("/api/projects/{project_id}/rfcs/{slug}") async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]: @@ -835,7 +874,9 @@ def make_router( _require_collection_in_project(collection_id, project_id) # §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer. auth.require_collection_readable(viewer, collection_id) - return _list_rfcs_for_collection(collection_id, viewer, unreviewed) + return _list_rfcs_for_collection( + collection_id, viewer, unreviewed, query_params=request.query_params + ) @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}") async def get_collection_rfc(