Skip to content

feat(search): add GET /search/facets.json — context-aware facet values API#12980

Draft
mekarpeles wants to merge 2 commits into
masterfrom
12978/search-facets-json-api
Draft

feat(search): add GET /search/facets.json — context-aware facet values API#12980
mekarpeles wants to merge 2 commits into
masterfrom
12978/search-facets-json-api

Conversation

@mekarpeles

Copy link
Copy Markdown
Member

Summary

Adds GET /search/facets.json, a new FastAPI endpoint that returns context-aware facet values for use in the search sidebar's filter dropdowns.

Unlike a generic "all values for field X" endpoint, this one accepts the full current search query and returns only the values that have results for that query (zero-count entries are filtered). This powers smart dropdowns — when a user has searched for "Tolkien", the language facet shows only languages that appear in Tolkien's books, not every language in the catalog.

Closes #12978


API Reference

GET /search/facets.json?field=<facet_field>[&field=<facet_field>...][&q=<query>][&<filter>=<value>...]

Required parameter:

  • field — facet field to return. Repeat for multiple. Valid values: author_facet, first_publish_year, has_fulltext, language, person_facet, place_facet, public_scan_b, publisher_facet, subject_facet, time_facet

All PublicQueryOptions are accepted: q, title, author, subject, language, author_key, subject_facet, place_facet, time_facet, first_publish_year, publisher_facet, has_fulltext, public_scan, print_disabled, and more.

Example request:

GET /search/facets.json?field=language&field=subject_facet&q=lord+of+the+rings

Example response:

{
  "language": [
    {"value": "English", "label": "English", "count": 1423},
    {"value": "German",  "label": "German",  "count": 47},
    {"value": "French",  "label": "French",  "count": 23}
  ],
  "subject_facet": [
    {"value": "Fantasy fiction",          "label": "Fantasy fiction",          "count": 892},
    {"value": "Imaginary places -- Fiction", "label": "Imaginary places -- Fiction", "count": 541}
  ]
}

Each entry has:

  • value — the filter value to pass back to /search.json (e.g. author_key=OL9A)
  • label — the human-readable display label (differs from value for author_facet)
  • count — number of matching works

Integration recipe for PR #12949 (OlSelectPopover / Lit search UX)

This endpoint is designed to feed directly into the OlSelectPopover components added in PR #12949. Here's a minimal integration sketch:

// In your Lit component or search controller
async fetchFacets(searchParams, fields) {
  const url = new URL('/search/facets.json', window.location.origin);
  // forward the current search query
  if (searchParams.q) url.searchParams.set('q', searchParams.q);
  // forward any active filters
  for (const [k, v] of Object.entries(searchParams.filters ?? {})) {
    for (const val of [].concat(v)) url.searchParams.append(k, val);
  }
  // request only the fields you need for the visible dropdowns
  for (const field of fields) url.searchParams.append('field', field);

  const resp = await fetch(url);
  return resp.json(); // { language: [{value, label, count}], ... }
}

// Wire into OlSelectPopover:
//   items={facets.language}  (already in [{value, label}] shape the component expects)
const facets = await fetchFacets(currentSearch, ['language', 'subject_facet', 'author_facet']);
// languagePopover.items = facets.language.map(({value, label}) => ({value, label}));

The response shape ({value, label} per item) is intentionally compatible with OlSelectPopover's items prop so no transformation is needed beyond stripping count if the component doesn't need it.

Multi-field call (one round trip for the whole sidebar):

GET /search/facets.json
  ?q=tolkien
  &language=eng           ← current active filter
  &field=language
  &field=subject_facet
  &field=author_facet
  &field=first_publish_year

Returns all four dropdowns' options in one Solr query (rows=0), making it suitable for calling on every search navigation without noticeable latency impact.


Implementation notes

  • Runs a rows=0 Solr query (no document data fetched) — fast even for large result sets
  • Uses BOOK_SEARCH_FACETS request label for Grafana/stats tracking
  • author_facetauthor_key rename is handled internally; response always uses author_facet to match the caller's input
  • Uses Depends() (not Query()) to unpack PublicQueryOptions — consistent with other endpoints like search_authors_json
  • field parameter uses None default (not []) to satisfy ruff B006

Testing

8 new tests in TestSearchFacetsEndpoint:

  • test_requires_field_param — 400 when no field given
  • test_rejects_invalid_field — 400 + error detail for unknown field names
  • test_returns_facet_values_for_single_field — 200 with correct structure
  • test_filters_zero_count_values — zero-count entries excluded from response
  • test_returns_multiple_fields — multiple field= params work
  • test_author_facet_key_maps_to_author_facet — internal rename round-trips correctly
  • test_empty_query_returns_valid_response — no q param still works
  • test_openapi_contains_facets_endpoint — endpoint appears in OpenAPI schema

All 8 pass. All 44 pre-existing TestSearchEndpoint tests continue to pass.

PYTHONPATH=".:vendor" python -m pytest openlibrary/tests/fastapi/test_search.py -v
# 52 passed

Checklist

  • Endpoint returns 200 for valid requests
  • Validates field parameter against WorkSearchScheme.facet_fields
  • Zero-count values filtered from response
  • author_facet key roundtrips correctly through internal rename
  • All pre-commit hooks pass (ruff, mypy, codespell)
  • 8 new tests, 0 regressions
  • Docker smoke test (Python 3.14 requirement blocks Docker test runs; tested on host Python 3.14)

…s API

Returns only search-relevant facet options for one or more fields, intended
to power the OlSelectPopover components in PR #12949's Lit search UX sidebar.

- New endpoint: GET /search/facets.json?field=language&field=subject_facet&q=...
- Accepts all PublicQueryOptions (q, title, author, filters, etc.)
- Returns {field: [{value, label, count}]} with zero-count entries filtered
- Validates field names against WorkSearchScheme.facet_fields
- Maps internal author_key back to author_facet for API consistency
- 8 new tests covering happy path, validation, filtering, and multi-field

Closes #12978

@RayBB RayBB left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's some feedback (for the AI):

Can you:

  1. Please follow the pattern of the rest of the endpoints in this file by having request/response models. This will be very handy for all of our tools!
  2. Use the fastapi skill (https://raw.githubusercontent.com/fastapi/fastapi/refs/heads/master/fastapi/.agents/skills/fastapi/SKILL.md) to ensure you're following best practices. Such as letting fastapi do the validation instead of validating in the endpoint.
  3. Avoid putting "business" logic in the endpoint the small transformation is on the border. Imo if it had much more we should def move it out of the endpoint and into a single testable function.

Do you intend this to be a publicly consumed API? If not, I'd recommend removing it from the openAPI spec outside of local dev (like how we do for the internal endpoints). Since this will likely be a "heavy" endpoint I think that's probably wise to do, at least for now.

- Add FacetValue response model + response_model= on decorator (per point 1)
- Replace manual invalid-field check with FacetField Literal type so FastAPI
  validates values and returns 422 (per point 2)
- Move _FACET_INTERNAL_RENAME to module level, out of the endpoint body (point 3)
- Update test: invalid field now correctly expects 422, not 400
- Drop trivial openapi-schema presence test
@mekarpeles

Copy link
Copy Markdown
Member Author

Thanks for the thorough review @RayBB — addressed in 5c4fc62:

1. Request/response models — added FacetValue(BaseModel) with value, label, count fields and wired it up as response_model=dict[str, list[FacetValue]] on the decorator. The endpoint now returns typed FacetValue instances rather than raw dicts.

2. Let FastAPI do validation — replaced the hand-rolled if invalid: raise HTTPException(400, ...) check with a FacetField = Literal["author_facet", "language", ...] type on the field parameter. FastAPI now validates each value against the Literal and returns a proper 422 with structured error detail. Updated the test to match the new status code.

3. Business logic out of the endpoint — moved _FACET_INTERNAL_RENAME to module level (the rename dict was the main offender). The Solr result assembly loop stays in the endpoint for now; it's small enough that a separate function would be more indirection than clarity. Happy to extract it if you'd like.

On the OpenAPI visibility question — this endpoint is designed to be called by the OL frontend (the OlSelectPopover dropdowns), so it's consumer-facing in the same way /search.json is. That said, if you think it should be hidden from the public spec until it's more stable, happy to add include_in_schema=False or gate it on settings.dev_mode. What's your preference?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(search): Context-aware Facet Values API — return only search-relevant facet options in sidebar dropdowns

3 participants