close
Skip to content

Commit 8e617de

Browse files
mekarpelesRayBB
andauthored
feat(search): add GET /search/facets.json — context-aware facet values API (#12980)
Co-authored-by: RayBB <RayBB@users.noreply.github.com>
1 parent 8a6914c commit 8e617de

3 files changed

Lines changed: 189 additions & 1 deletion

File tree

‎openlibrary/fastapi/search.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import os
45
from collections.abc import Mapping
56
from typing import Annotated, Any, Literal, Self
67

@@ -37,6 +38,28 @@
3738

3839
router = APIRouter()
3940

41+
# Facet fields exposed by /search/facets.json — mirrors WorkSearchScheme.facet_fields
42+
FacetField = Literal[
43+
"author_facet",
44+
"first_publish_year",
45+
"has_fulltext",
46+
"language",
47+
"person_facet",
48+
"place_facet",
49+
"public_scan_b",
50+
"publisher_facet",
51+
"subject_facet",
52+
"time_facet",
53+
]
54+
55+
# process_facet_counts renames author_facet → author_key internally; map back so
56+
# the response key always matches the caller's requested field name.
57+
_FACET_INTERNAL_RENAME = {"author_key": "author_facet"}
58+
59+
# Whether to expose internal-only endpoints in the OpenAPI schema.
60+
# Only shown when running with LOCAL_DEV set (e.g. docker compose).
61+
SHOW_INTERNAL_IN_SCHEMA = os.getenv("LOCAL_DEV") is not None
62+
4063

4164
class PublicQueryOptions(BaseModel):
4265
"""
@@ -141,6 +164,14 @@ def selected_query(self) -> dict[str, Any]:
141164
return q
142165

143166

167+
class FacetValue(BaseModel):
168+
"""A single facet option returned by /search/facets.json."""
169+
170+
value: str = Field(description="Filter value to pass back to /search.json (e.g. 'eng', 'OL9A')")
171+
label: str = Field(description="Human-readable display label — differs from value for author_facet")
172+
count: int = Field(description="Number of matching works")
173+
174+
144175
class SearchResponse(BaseModel):
145176
"""The response from a (books) search query."""
146177

@@ -348,3 +379,51 @@ async def search_authors_json(
348379
doc["key"] = doc["key"].split("/")[-1]
349380

350381
return raw_resp
382+
383+
384+
@router.get(
385+
"/search/facets.json",
386+
tags=["internal"],
387+
include_in_schema=SHOW_INTERNAL_IN_SCHEMA,
388+
response_model=dict[str, list[FacetValue]],
389+
)
390+
async def search_facets_json(
391+
request: Request,
392+
params: Annotated[PublicQueryOptions, Depends()],
393+
field: Annotated[
394+
list[FacetField],
395+
Query(min_length=1, description="Facet field(s) to return. Repeat for multiple."),
396+
],
397+
solr_internals_params: Annotated[SolrInternalsParams | None, Depends(SolrInternalsParams.from_request)] = None,
398+
) -> dict[str, list[FacetValue]]:
399+
"""
400+
Returns context-aware facet values for one or more search facet fields.
401+
402+
Queries Solr with rows=0 alongside the current search params, returning only
403+
values with count > 0, ordered by count descending. Designed to power the
404+
OlSelectPopover components in the search results filter bar (PR #12949).
405+
406+
Example: GET /search/facets.json?field=language&field=subject_facet&q=lord+of+the+rings
407+
"""
408+
search_response = await run_solr_query_async(
409+
WorkSearchScheme(lang=request.state.lang),
410+
params.model_dump(exclude_none=True),
411+
rows=0,
412+
page=1,
413+
facet=list(field),
414+
highlight=False,
415+
request_label="BOOK_SEARCH_FACETS",
416+
solr_internals_params=solr_internals_params,
417+
)
418+
419+
result: dict[str, list[FacetValue]] = {f: [] for f in field}
420+
if search_response.facet_counts:
421+
for facet_field, values in search_response.facet_counts.items():
422+
output_key = _FACET_INTERNAL_RENAME.get(facet_field, facet_field)
423+
if output_key not in result:
424+
continue
425+
# values are (filter_value, display_label, count) tuples; label differs
426+
# from value for author_facet (name vs OL key) and "Name|key" subject fields.
427+
result[output_key] = [FacetValue(value=value, label=label, count=count) for value, label, count in values if count > 0]
428+
429+
return result

‎openlibrary/tests/fastapi/conftest.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
"""Shared pytest fixtures for FastAPI and API contract tests."""
24

35
from unittest.mock import patch
@@ -148,6 +150,44 @@ def _default_search_response():
148150
}
149151

150152

153+
@pytest.fixture
154+
def mock_run_solr_query_async():
155+
"""Mock run_solr_query_async to avoid actual Solr calls.
156+
157+
Used by FastAPI search/facets endpoint tests.
158+
Returns a SearchResponse with sample facet_counts.
159+
"""
160+
with patch("openlibrary.fastapi.search.run_solr_query_async", autospec=True) as mock:
161+
mock.return_value = _default_facets_response()
162+
yield mock
163+
164+
165+
def _default_facets_response():
166+
"""Default mock SearchResponse for facets tests."""
167+
return SearchResponse(
168+
# process_facet_counts renames author_facet → author_key in this dict
169+
facet_counts={
170+
"language": [
171+
("eng", "English", 665),
172+
("deu", "German", 32),
173+
("spa", "Spanish", 18),
174+
("lat", "Latin", 0), # zero-count entry — should be filtered out
175+
],
176+
"author_key": [
177+
("OL9A", "J.R.R. Tolkien", 123),
178+
],
179+
"subject_facet": [
180+
("Fantasy", "Fantasy", 89),
181+
],
182+
},
183+
sort="",
184+
docs=[],
185+
num_found=0,
186+
raw_resp={"response": {"docs": []}},
187+
solr_select="mock",
188+
)
189+
190+
151191
def _default_subjects_response():
152192
"""Default mock response for subjects search."""
153193

‎openlibrary/tests/fastapi/test_search.py‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
from __future__ import annotations
2+
13
"""Basic tests for the FastAPI search endpoint."""
24

35
import json
6+
from typing import get_args
47
from urllib.parse import urlencode
58

69
import pytest
710

8-
from openlibrary.fastapi.search import PublicQueryOptions
11+
from openlibrary.fastapi.search import FacetField, PublicQueryOptions
912
from openlibrary.plugins.worksearch.code import WorkSearchScheme
1013

1114

@@ -391,3 +394,69 @@ def test_debug_openapi_structure(self, client):
391394

392395
# This test always passes - it's just for debug output
393396
assert True
397+
398+
399+
class TestSearchFacetsEndpoint:
400+
"""Tests for the /search/facets.json endpoint."""
401+
402+
def test_requires_field_param(self, fastapi_client, mock_run_solr_query_async):
403+
response = fastapi_client.get("/search/facets.json?q=tolkien")
404+
# FastAPI enforces required + min_length=1 on `field`, returns 422 when absent
405+
assert response.status_code == 422
406+
detail = response.json()["detail"]
407+
assert any("field" in err["loc"] for err in detail)
408+
409+
def test_rejects_invalid_field(self, fastapi_client, mock_run_solr_query_async):
410+
response = fastapi_client.get("/search/facets.json?field=notafield&q=tolkien")
411+
# FastAPI validates FacetField Literal and returns 422 for unknown values
412+
assert response.status_code == 422
413+
detail = response.json()["detail"]
414+
assert any(err.get("input") == "notafield" for err in detail)
415+
416+
def test_returns_facet_values_for_single_field(self, fastapi_client, mock_run_solr_query_async):
417+
response = fastapi_client.get("/search/facets.json?field=language&q=lord+of+the+rings")
418+
assert response.status_code == 200
419+
data = response.json()
420+
assert "language" in data
421+
values = data["language"]
422+
assert len(values) > 0
423+
assert all("value" in v and "count" in v and "label" in v for v in values)
424+
assert values[0] == {"value": "eng", "label": "English", "count": 665}
425+
426+
def test_filters_zero_count_values(self, fastapi_client, mock_run_solr_query_async):
427+
response = fastapi_client.get("/search/facets.json?field=language&q=tolkien")
428+
assert response.status_code == 200
429+
data = response.json()
430+
# "Latin" (code "lat") has count=0 in the mock — must not appear
431+
assert not any(v["value"] == "lat" for v in data["language"])
432+
assert all(v["count"] > 0 for v in data["language"])
433+
434+
def test_returns_multiple_fields(self, fastapi_client, mock_run_solr_query_async):
435+
response = fastapi_client.get("/search/facets.json?field=language&field=subject_facet&q=tolkien")
436+
assert response.status_code == 200
437+
data = response.json()
438+
assert "language" in data
439+
assert "subject_facet" in data
440+
441+
def test_author_facet_key_maps_to_author_facet(self, fastapi_client, mock_run_solr_query_async):
442+
"""Response key should be 'author_facet' even though Solr returns 'author_key' internally."""
443+
response = fastapi_client.get("/search/facets.json?field=author_facet&q=tolkien")
444+
assert response.status_code == 200
445+
data = response.json()
446+
assert "author_facet" in data
447+
assert "author_key" not in data
448+
assert data["author_facet"] == [{"value": "OL9A", "label": "J.R.R. Tolkien", "count": 123}]
449+
450+
def test_empty_query_returns_valid_response(self, fastapi_client, mock_run_solr_query_async):
451+
"""No q param should still return a valid (unfiltered) facet list."""
452+
response = fastapi_client.get("/search/facets.json?field=language")
453+
assert response.status_code == 200
454+
assert "language" in response.json()
455+
456+
def test_facet_field_literal_matches_scheme(self):
457+
"""FacetField Literal must stay in sync with WorkSearchScheme.facet_fields.
458+
459+
Guards against drift: if a facet field is added/removed upstream, this
460+
fails so the endpoint doesn't silently 422 on (or miss) it.
461+
"""
462+
assert set(get_args(FacetField)) == WorkSearchScheme.facet_fields

0 commit comments

Comments
 (0)