Skip to content
Open
96 changes: 82 additions & 14 deletions openlibrary/fastapi/lists.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Annotated, Literal
Comment thread
harshgupta2125 marked this conversation as resolved.
Outdated

from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, status
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request, status
Comment thread
harshgupta2125 marked this conversation as resolved.
Outdated

from infogami.infobase import client
from openlibrary.accounts import get_current_user
from openlibrary.fastapi.auth import AuthenticatedUser, require_authenticated_user
from openlibrary.plugins.openlibrary.lists import ListEditionsModel, ListSubjectsModel, get_list, get_list_editions, get_list_subjects
from openlibrary.plugins.openlibrary.lists import ListEditionsModel, ListSubjectsModel, get_list, get_list_editions, get_list_seeds, get_list_subjects
from openlibrary.plugins.openlibrary.lists import list_seeds as _LegacyListSeeds
from openlibrary.plugins.openlibrary.lists import lists_delete as _LegacyListsDelete
from openlibrary.utils.request_context import site, web_ctx_ip

Expand Down Expand Up @@ -114,8 +113,77 @@ async def lists_json():
pass


async def list_seeds():
pass
def _get_list_seeds_or_404(key: str) -> dict:
lst = get_list_seeds(key)
if lst is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List or Series not found")
return lst


def _update_list_seeds(key: str, payload: dict) -> dict:
s = site.get()
lst = s.get(key)

if lst is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List or Series not found")

if not s.can_write(key):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied.")

try:
# Pass the payload safely directly to the legacy processor
data = {"add": payload.get("add", []), "remove": payload.get("remove", [])}
return _LegacyListSeeds.process_seeds_update(lst, data, key)
Comment thread
harshgupta2125 marked this conversation as resolved.
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@router.get("/people/{username}/lists/{olid}/seeds.json")
def list_seeds_json_user(username: UsernamePath, olid: ListOLID) -> dict:
return _get_list_seeds_or_404(f"/people/{username}/lists/{olid}")


Comment thread
harshgupta2125 marked this conversation as resolved.
@router.get("/people/{username}/series/{olid}/seeds.json")
def series_seeds_json_user(username: UsernamePath, olid: ListOLID) -> dict:
return _get_list_seeds_or_404(f"/people/{username}/series/{olid}")


@router.get("/lists/{olid}/seeds.json")
def list_seeds_json_public(olid: ListOLID) -> dict:
return _get_list_seeds_or_404(f"/lists/{olid}")


@router.get("/series/{olid}/seeds.json")
def series_seeds_json_public(olid: ListOLID) -> dict:
return _get_list_seeds_or_404(f"/series/{olid}")


@router.post("/people/{username}/lists/{olid}/seeds.json")
def update_list_seeds_json_user(
username: UsernamePath, olid: ListOLID, payload: Annotated[dict, Body(...)], _: Annotated[AuthenticatedUser, Depends(require_authenticated_user)]
) -> dict:
return _update_list_seeds(f"/people/{username}/lists/{olid}", payload)


@router.post("/people/{username}/series/{olid}/seeds.json")
def update_series_seeds_json_user(
username: UsernamePath, olid: ListOLID, payload: Annotated[dict, Body(...)], _: Annotated[AuthenticatedUser, Depends(require_authenticated_user)]
) -> dict:
return _update_list_seeds(f"/people/{username}/series/{olid}", payload)


@router.post("/lists/{olid}/seeds.json")
def update_list_seeds_json_public(
olid: ListOLID, payload: Annotated[dict, Body(...)], _: Annotated[AuthenticatedUser, Depends(require_authenticated_user)]
) -> dict:
return _update_list_seeds(f"/lists/{olid}", payload)


@router.post("/series/{olid}/seeds.json")
def update_series_seeds_json_public(
olid: ListOLID, payload: Annotated[dict, Body(...)], _: Annotated[AuthenticatedUser, Depends(require_authenticated_user)]
Comment thread
harshgupta2125 marked this conversation as resolved.
) -> dict:
return _update_list_seeds(f"/series/{olid}", payload)


class GetListEditionsParams:
Expand All @@ -140,7 +208,7 @@ def _get_editions_response(key: str, params: GetListEditionsParams) -> ListEditi
CommonPagination = Annotated[GetListEditionsParams, Depends()]


@router.get("/lists/{olid}/editions.json", response_model=ListEditionsModel)
@router.get("/lists/{olid}/editions.json")
def list_editions_json(olid: ListOLID, params: CommonPagination) -> ListEditionsModel:
"""
Get paginated editions for a public list.
Expand All @@ -149,7 +217,7 @@ def list_editions_json(olid: ListOLID, params: CommonPagination) -> ListEditions
return _get_editions_response(key, params)


@router.get("/people/{username}/lists/{olid}/editions.json", response_model=ListEditionsModel)
@router.get("/people/{username}/lists/{olid}/editions.json")
def list_editions_json_people(username: UsernamePath, olid: ListOLID, params: CommonPagination) -> ListEditionsModel:
"""
Get paginated editions for a specific user's list.
Expand All @@ -158,7 +226,7 @@ def list_editions_json_people(username: UsernamePath, olid: ListOLID, params: Co
return _get_editions_response(key, params)


@router.get("/series/{olid}/editions.json", response_model=ListEditionsModel)
@router.get("/series/{olid}/editions.json")
def series_editions_json(olid: ListOLID, params: CommonPagination) -> ListEditionsModel:
"""
Get paginated editions for a specific series.
Expand All @@ -167,7 +235,7 @@ def series_editions_json(olid: ListOLID, params: CommonPagination) -> ListEditio
return _get_editions_response(key, params)


@router.get("/people/{username}/series/{olid}/editions.json", response_model=ListEditionsModel)
@router.get("/people/{username}/series/{olid}/editions.json")
def series_editions_json_people(username: UsernamePath, olid: ListOLID, params: CommonPagination) -> ListEditionsModel:
"""
Get paginated editions for a specific user's series.
Expand All @@ -179,31 +247,31 @@ def series_editions_json_people(username: UsernamePath, olid: ListOLID, params:
CommonSubjectsLimit = Annotated[int, Query(ge=0, description="Number of subjects to return")]


@router.get("/people/{username}/lists/{olid}/subjects.json", response_model=ListSubjectsModel)
@router.get("/people/{username}/lists/{olid}/subjects.json")
def list_subjects_json_user(username: UsernamePath, olid: ListOLID, limit: CommonSubjectsLimit = 20) -> ListSubjectsModel:
key = f"/people/{username}/lists/{olid}"
if data := get_list_subjects(key, limit):
return data
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found")


@router.get("/people/{username}/series/{olid}/subjects.json", response_model=ListSubjectsModel)
@router.get("/people/{username}/series/{olid}/subjects.json")
def list_subjects_json_user_series(username: UsernamePath, olid: ListOLID, limit: CommonSubjectsLimit = 20) -> ListSubjectsModel:
key = f"/people/{username}/series/{olid}"
if data := get_list_subjects(key, limit):
return data
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found")


@router.get("/lists/{olid}/subjects.json", response_model=ListSubjectsModel)
@router.get("/lists/{olid}/subjects.json")
def list_subjects_json_public(olid: ListOLID, limit: CommonSubjectsLimit = 20) -> ListSubjectsModel:
key = f"/lists/{olid}"
if data := get_list_subjects(key, limit):
return data
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found")


@router.get("/series/{olid}/subjects.json", response_model=ListSubjectsModel)
@router.get("/series/{olid}/subjects.json")
def list_subjects_json_series(olid: ListOLID, limit: CommonSubjectsLimit = 20) -> ListSubjectsModel:
key = f"/series/{olid}"
if data := get_list_subjects(key, limit):
Expand Down
2 changes: 1 addition & 1 deletion openlibrary/plugins/openlibrary/lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,7 @@ class list_view_yaml(list_view_json):


def get_list_seeds(key):
if lst := web.ctx.site.get(key):
if lst := site.get().get(key):
seeds = [seed.dict() for seed in lst.get_seeds()]
return {
"links": {"self": key + "/seeds", "list": key},
Expand Down
40 changes: 40 additions & 0 deletions openlibrary/tests/fastapi/test_list_seeds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
def test_get_list_seeds_public_success(fastapi_client, monkeypatch):
"""Test fetching seeds for a known public list."""

def mock_get_list_seeds(key):
return {"entries": [{"url": "/works/OL123W", "type": "work"}], "size": 1}

monkeypatch.setattr("openlibrary.fastapi.lists.get_list_seeds", mock_get_list_seeds)

response = fastapi_client.get("/lists/OL3L/seeds.json")
assert response.status_code == 200
data = response.json()
assert "entries" in data
assert data["size"] == 1


def test_get_list_seeds_not_found(fastapi_client, monkeypatch):
"""Test fetching seeds for a list that does not exist."""

def mock_get_list_seeds(key):
return None

monkeypatch.setattr("openlibrary.fastapi.lists.get_list_seeds", mock_get_list_seeds)

response = fastapi_client.get("/lists/OL999999L/seeds.json")
assert response.status_code == 404
assert response.json() == {"detail": "List or Series not found"}


def test_post_list_seeds_unauthorized(fastapi_client, mock_site):
"""Test that unauthorized users cannot mutate seeds."""
payload = {"add": ["/works/OL123W"], "remove": []}
response = fastapi_client.post("/lists/OL3L/seeds.json", json=payload)
assert response.status_code in [401, 403]


def test_post_list_seeds_authorized(fastapi_client, mock_authenticated_user, mock_site):
"""Test that a logged-in user successfully hits the permission logic."""
payload = {"add": ["/works/OL123W"], "remove": []}
response = fastapi_client.post("/lists/OL3L/seeds.json", json=payload)
assert response.status_code in [200, 403, 404]