Skip to content

Commit 88000ee

Browse files
Migrate trending_books_api endpoint to FastAPI (#12000)
Co-authored-by: RayBB <RayBB@users.noreply.github.com>
1 parent 66a02c4 commit 88000ee

6 files changed

Lines changed: 299 additions & 26 deletions

File tree

openlibrary/fastapi/internal/api.py

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,37 +9,99 @@
99
from __future__ import annotations
1010

1111
import os
12-
from typing import Annotated
12+
from typing import Annotated, Literal
1313

14+
import web
1415
from fastapi import APIRouter, Depends, Form, Path, Query
15-
from pydantic import BaseModel, Field
16+
from pydantic import BaseModel, BeforeValidator, Field
1617

1718
from openlibrary.core import lending
1819
from openlibrary.core.models import Booknotes
1920
from openlibrary.fastapi.auth import AuthenticatedUser, require_authenticated_user
20-
from openlibrary.fastapi.models import Pagination # noqa: TC001
21+
from openlibrary.fastapi.models import Pagination, parse_comma_separated_list
2122
from openlibrary.utils import extract_numeric_id_from_olid
22-
23-
router = APIRouter()
23+
from openlibrary.utils.request_context import site as site_ctx
24+
from openlibrary.views.loanstats import SINCE_DAYS, get_trending_books
2425

2526
SHOW_INTERNAL_IN_SCHEMA = os.getenv("LOCAL_DEV") is not None
27+
router = APIRouter(tags=["internal"], include_in_schema=SHOW_INTERNAL_IN_SCHEMA)
28+
29+
# Valid period values — mirrors SINCE_DAYS keys
30+
# IMPORTANT: Keep this Literal in sync with the keys of views.loanstats.SINCE_DAYS!
31+
# This guarantees that our path parameter validated by FastAPI is always
32+
# a safe dictionary key when we pass it to the legacy backend function.
33+
TrendingPeriod = Literal["now", "daily", "weekly", "monthly", "yearly", "forever"]
2634

2735

28-
@router.get("/availability/v2", tags=["internal"], include_in_schema=SHOW_INTERNAL_IN_SCHEMA)
36+
@router.get("/availability/v2")
2937
async def book_availability():
3038
pass
3139

3240

33-
@router.get("/trending/{period}.json", tags=["internal"], include_in_schema=SHOW_INTERNAL_IN_SCHEMA)
34-
async def trending_books_api(period: str):
35-
pass
41+
class TrendingRequestParams(Pagination):
42+
limit: int = Field(100, ge=0, le=1000, description="Maximum number of results per page.")
43+
hours: int = Field(0, ge=0, description="Custom number of hours to look back.")
44+
days: int = Field(0, ge=0, description="Custom number of days to look back.")
45+
sort_by_count: bool = Field(True, description="Sort results by total log count (most-logged first).")
46+
minimum: int = Field(0, ge=0, description="Minimum log count a book must have to be included.")
47+
fields: Annotated[list[str] | None, BeforeValidator(parse_comma_separated_list)] = Field(
48+
None,
49+
description="Comma-separated list of Solr fields to include in each work.",
50+
)
51+
52+
53+
class SolrWork(BaseModel):
54+
key: str
55+
title: str | None = None
56+
author_name: list[str] | None = None
57+
58+
model_config = {"extra": "allow"}
59+
60+
61+
class TrendingResponse(BaseModel):
62+
query: str = Field(..., description="Echo of the request path, e.g. /trending/daily")
63+
works: list[SolrWork] = Field(..., description="Trending work documents from Solr")
64+
days: int | None = Field(default=None, description="Look-back window in days (None = all-time)")
65+
hours: int = Field(..., description="Look-back window in hours")
3666

3767

3868
@router.get(
39-
"/browse.json",
40-
tags=["internal"],
41-
include_in_schema=SHOW_INTERNAL_IN_SCHEMA,
69+
"/trending/{period}.json",
70+
response_model=TrendingResponse,
71+
response_model_exclude_none=True,
72+
description="Returns works sorted by recent activity (reads, loans, etc.)",
4273
)
74+
def trending_books_api(
75+
period: Annotated[TrendingPeriod, Path(description="The time period for trending books")],
76+
params: Annotated[TrendingRequestParams, Query()],
77+
) -> TrendingResponse:
78+
"""Fetch trending books for the given period."""
79+
# ``period`` is always a key in SINCE_DAYS — guaranteed by the Literal type above.
80+
since_days: int | None = SINCE_DAYS.get(period, params.days)
81+
82+
# Setting web.ctx.site is an ANTIPATTERN and we should avoid it elsewhere.
83+
# It will be removed via #12178
84+
web.ctx.site = site_ctx.get()
85+
86+
works = get_trending_books(
87+
since_days=since_days,
88+
since_hours=params.hours,
89+
limit=params.limit,
90+
page=params.page,
91+
sort_by_count=params.sort_by_count,
92+
minimum=params.minimum,
93+
fields=params.fields,
94+
)
95+
96+
return TrendingResponse(
97+
query=f"/trending/{period}",
98+
works=[SolrWork(**dict(work)) for work in works],
99+
days=since_days,
100+
hours=params.hours,
101+
)
102+
103+
104+
@router.get("/browse.json")
43105
async def browse(
44106
pagination: Annotated[Pagination, Depends()],
45107
q: Annotated[str, Query()] = "",
@@ -73,12 +135,7 @@ class BooknoteResponse(BaseModel):
73135
success: str = Field(..., description="Status message")
74136

75137

76-
@router.post(
77-
"/works/OL{work_id}W/notes",
78-
response_model=BooknoteResponse,
79-
tags=["internal"],
80-
include_in_schema=SHOW_INTERNAL_IN_SCHEMA,
81-
)
138+
@router.post("/works/OL{work_id}W/notes", response_model=BooknoteResponse)
82139
async def booknotes_post(
83140
work_id: Annotated[int, Path(gt=0)],
84141
user: Annotated[AuthenticatedUser, Depends(require_authenticated_user)],

openlibrary/fastapi/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ class PaginationLimit20(Pagination):
3030
limit: int = Field(20, ge=0, description="Maximum number of results to return.")
3131

3232

33+
def parse_comma_separated_list(v: str | list[str] | None) -> list[str]:
34+
if not v:
35+
return []
36+
if isinstance(v, str):
37+
v = [v]
38+
return [f.strip() for item in v for f in str(item).split(",") if f.strip()]
39+
40+
3341
class SolrInternalsParams(BaseModel):
3442
"""
3543
Internal Solr query parameters for A/B testing search configurations.

openlibrary/fastapi/search.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
Pagination,
2323
PaginationLimit20,
2424
SolrInternalsParams,
25+
parse_comma_separated_list,
2526
)
2627
from openlibrary.plugins.worksearch.code import (
2728
default_spellcheck_count,
@@ -97,7 +98,7 @@ def parse_q_string(cls, v: str) -> str:
9798

9899

99100
class SearchRequestParams(PublicQueryOptions, Pagination):
100-
fields: Annotated[list[str], BeforeValidator(parse_fields_string)] = Field(
101+
fields: Annotated[list[str], BeforeValidator(parse_comma_separated_list)] = Field(
101102
sorted(WorkSearchScheme.default_fetched_fields),
102103
description="The fields to return.",
103104
)
@@ -110,12 +111,6 @@ class SearchRequestParams(PublicQueryOptions, Pagination):
110111
description="The number of spellcheck suggestions.",
111112
)
112113

113-
@staticmethod
114-
def parse_fields_string(v: str | list[str]) -> list[str]:
115-
if isinstance(v, str):
116-
v = [v]
117-
return [f.strip() for item in v for f in str(item).split(",") if f.strip()]
118-
119114
@staticmethod
120115
def parse_query_json(v: str) -> dict[str, Any]:
121116
try:

openlibrary/plugins/openlibrary/api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def get_book_availability(id_type, ids):
7878
return []
7979

8080

81+
@deprecated("migrated to fastapi")
8182
class trending_books_api(delegate.page):
8283
path = "/trending(/?.*)"
8384
# path = "/trending/(now|daily|weekly|monthly|yearly|forever)"

openlibrary/tests/fastapi/test_models.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,61 @@
1-
from openlibrary.fastapi.models import SolrInternalsParams
1+
import pytest
2+
3+
from openlibrary.fastapi.models import SolrInternalsParams, parse_comma_separated_list
4+
5+
6+
class TestParseCommaSeparatedList:
7+
"""Tests for parse_comma_separated_list utility function.
8+
9+
This function is used by FastAPI endpoints to parse comma-separated
10+
parameters from query strings into a clean list of values.
11+
"""
12+
13+
@pytest.mark.parametrize(
14+
("input_val", "expected"),
15+
[
16+
(None, []),
17+
("", []),
18+
([], []),
19+
],
20+
)
21+
def test_falsy_inputs_return_empty_list(self, input_val, expected):
22+
assert parse_comma_separated_list(input_val) == expected
23+
24+
@pytest.mark.parametrize(
25+
("input_val", "expected"),
26+
[
27+
("key", ["key"]),
28+
(["key"], ["key"]),
29+
],
30+
)
31+
def test_single_item(self, input_val, expected):
32+
assert parse_comma_separated_list(input_val) == expected
33+
34+
def test_multiple_comma_separated_items(self):
35+
assert parse_comma_separated_list("key,title,author_name") == ["key", "title", "author_name"]
36+
37+
def test_whitespace_trimming(self):
38+
assert parse_comma_separated_list(" key , title , author_name ") == ["key", "title", "author_name"]
39+
40+
def test_empty_values_filtered(self):
41+
assert parse_comma_separated_list("key,,title") == ["key", "title"]
42+
43+
def test_list_with_comma_separated_strings(self):
44+
assert parse_comma_separated_list(["key", "title,author_name"]) == ["key", "title", "author_name"]
45+
46+
def test_list_with_whitespace_and_empty_strings(self):
47+
assert parse_comma_separated_list([" key ", "", " title "]) == ["key", "title"]
48+
49+
def test_real_world_complex_case(self):
50+
assert parse_comma_separated_list("key,title,subtitle,author_name,author_key,cover_i,cover_edition_key") == [
51+
"key",
52+
"title",
53+
"subtitle",
54+
"author_name",
55+
"author_key",
56+
"cover_i",
57+
"cover_edition_key",
58+
]
259

360

461
class TestSolrInternalsParams:
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Tests for the /trending/{period}.json FastAPI endpoint (internal API)."""
2+
3+
import os
4+
from unittest.mock import patch
5+
6+
import pytest
7+
8+
MOCK_WORKS = [
9+
{"key": "/works/OL1W", "title": "Popular Book 1", "author_name": ["Author A"]},
10+
{"key": "/works/OL2W", "title": "Popular Book 2", "author_name": ["Author B"]},
11+
]
12+
13+
14+
@pytest.fixture
15+
def mock_get_trending_books():
16+
with patch("openlibrary.fastapi.internal.api.get_trending_books", return_value=MOCK_WORKS) as mock:
17+
yield mock
18+
19+
20+
class TestTrendingBooksEndpoint:
21+
"""Tests for GET /trending/{period}.json."""
22+
23+
@pytest.mark.parametrize(
24+
("period", "expected_days"),
25+
[
26+
("daily", 1),
27+
("weekly", 7),
28+
("monthly", 30),
29+
("yearly", 365),
30+
("forever", None),
31+
("now", 0),
32+
],
33+
)
34+
def test_valid_periods(self, fastapi_client, mock_get_trending_books, period, expected_days):
35+
response = fastapi_client.get(f"/trending/{period}.json")
36+
response.raise_for_status()
37+
data = response.json()
38+
assert data["query"] == f"/trending/{period}"
39+
# When expected_days is None (forever), 'days' shouldn't be in the response because of response_model_exclude_none=True
40+
if expected_days is None:
41+
assert "days" not in data
42+
else:
43+
assert data["days"] == expected_days
44+
assert isinstance(data["works"], list)
45+
46+
def test_empty_works_list(self, fastapi_client, mock_get_trending_books):
47+
mock_get_trending_books.return_value = []
48+
response = fastapi_client.get("/trending/now.json")
49+
response.raise_for_status()
50+
assert response.json()["works"] == []
51+
52+
@pytest.mark.parametrize(
53+
("query_string", "expected_kwargs"),
54+
[
55+
("hours=12", {"since_hours": 12}),
56+
("minimum=3", {"minimum": 3}),
57+
("fields=key,title", {"fields": ["key", "title"]}),
58+
("sort_by_count=false", {"sort_by_count": False}),
59+
("page=2&limit=10", {"page": 2, "limit": 10}),
60+
("fields=key,%20title", {"fields": ["key", "title"]}),
61+
("", {"sort_by_count": True, "fields": None}),
62+
(
63+
"page=3&limit=50&hours=6&sort_by_count=false&minimum=10&fields=key,title",
64+
{"page": 3, "limit": 50, "since_hours": 6, "sort_by_count": False, "minimum": 10, "fields": ["key", "title"]},
65+
),
66+
(
67+
"fields=key,title,subtitle,author_name,author_key,cover_i,cover_edition_key",
68+
{"fields": ["key", "title", "subtitle", "author_name", "author_key", "cover_i", "cover_edition_key"]},
69+
),
70+
(
71+
"fields=key,title,ia,ia_collection",
72+
{"fields": ["key", "title", "ia", "ia_collection"]},
73+
),
74+
],
75+
)
76+
def test_query_params_forwarded(self, fastapi_client, mock_get_trending_books, query_string, expected_kwargs):
77+
response = fastapi_client.get(f"/trending/daily.json?{query_string}")
78+
response.raise_for_status()
79+
for k, v in expected_kwargs.items():
80+
assert mock_get_trending_books.call_args.kwargs[k] == v
81+
82+
@pytest.mark.parametrize(
83+
("url", "description"),
84+
[
85+
("/trending/badperiod.json", "invalid period"),
86+
("/trending/daily.json?sort_by_count=maybe", "invalid boolean"),
87+
("/trending/daily.json?page=0", "page zero"),
88+
("/trending/daily.json?limit=1001", "limit exceeds max"),
89+
],
90+
)
91+
def test_validation_errors(self, fastapi_client, mock_get_trending_books, url, description):
92+
assert fastapi_client.get(url).status_code == 422
93+
mock_get_trending_books.assert_not_called()
94+
95+
@pytest.mark.parametrize(
96+
("hours_param", "expected_hours"),
97+
[("6", 6), (None, 0)],
98+
)
99+
def test_hours_in_response(self, fastapi_client, mock_get_trending_books, hours_param, expected_hours):
100+
url = "/trending/daily.json"
101+
if hours_param:
102+
url += f"?hours={hours_param}"
103+
response = fastapi_client.get(url)
104+
response.raise_for_status()
105+
assert response.json()["hours"] == expected_hours
106+
107+
def test_limit_defaults_to_100(self, fastapi_client, mock_get_trending_books):
108+
response = fastapi_client.get("/trending/daily.json")
109+
response.raise_for_status()
110+
assert mock_get_trending_books.call_args.kwargs["limit"] == 100
111+
112+
def test_trending_period_literal_matches_since_days(self):
113+
from typing import get_args
114+
115+
from openlibrary.fastapi.internal.api import TrendingPeriod
116+
from openlibrary.views.loanstats import SINCE_DAYS
117+
118+
literal_keys = set(get_args(TrendingPeriod))
119+
since_days_keys = set(SINCE_DAYS.keys())
120+
121+
assert literal_keys == since_days_keys, (
122+
"TrendingPeriod Literal must stay in sync with views.loanstats.SINCE_DAYS keys. "
123+
f"Missing in Literal: {since_days_keys - literal_keys}. "
124+
f"Extra in Literal: {literal_keys - since_days_keys}."
125+
)
126+
127+
def test_works_content_in_response(self, fastapi_client, mock_get_trending_books):
128+
response = fastapi_client.get("/trending/daily.json")
129+
response.raise_for_status()
130+
works = response.json()["works"]
131+
assert len(works) == 2
132+
assert works[0]["key"] == "/works/OL1W"
133+
assert works[1]["key"] == "/works/OL2W"
134+
135+
136+
@pytest.mark.skipif(
137+
os.getenv("LOCAL_DEV") is None,
138+
reason="Trending endpoint is excluded from OpenAPI schema outside LOCAL_DEV (include_in_schema=False)",
139+
)
140+
class TestOpenAPIDocumentation:
141+
"""Verify the trending endpoint is correctly described in the OpenAPI schema."""
142+
143+
def test_openapi_contains_trending_endpoint(self, fastapi_client):
144+
response = fastapi_client.get("/openapi.json")
145+
assert response.status_code == 200
146+
paths = response.json()["paths"]
147+
assert "/trending/{period}.json" in paths
148+
149+
def test_openapi_trending_params_have_descriptions(self, fastapi_client):
150+
response = fastapi_client.get("/openapi.json")
151+
assert response.status_code == 200
152+
params = response.json()["paths"]["/trending/{period}.json"]["get"]["parameters"]
153+
by_name = {p["name"]: p for p in params}
154+
assert by_name["period"]["description"]
155+
assert by_name["hours"]["description"]

0 commit comments

Comments
 (0)