Skip to content

Commit 8bf1fda

Browse files
authored
Merge pull request #32 from andreagrandi/feature/api-countries-endpoint
Add GET /api/v1/libraries/countries/ endpoint
2 parents c2dd740 + ef8cf0d commit 8bf1fda

File tree

10 files changed

+282
-2
lines changed

10 files changed

+282
-2
lines changed

book-corners-plan.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,3 +1419,22 @@ if search:
14191419

14201420
- [ ] Add `search` param to `LibrarySearchParams` schema
14211421
- [ ] Add OR filter logic in `run_library_search()` in `libraries/search.py`
1422+
1423+
#### 9.5 — Add `GET /libraries/countries/` endpoint
1424+
1425+
The iOS map filter needs a country picker showing all countries that have at least one
1426+
approved library. The current `/statistics/` endpoint only returns the top 10 countries.
1427+
1428+
Add a lightweight endpoint that returns all distinct countries with counts, using the
1429+
same query pattern as `top_countries` in `stats.py` but without the `[:10]` limit.
1430+
1431+
Response: `{ "items": [{ "country_code": "FR", "country_name": "France", "flag_emoji": "🇫🇷", "count": 15925 }, ...] }`
1432+
1433+
The list should be ordered by count descending (most libraries first).
1434+
1435+
- [x] Add `CountryListOut` schema in `api_schemas.py` (reuse `CountryStatOut`)
1436+
- [x] Add `get_countries()` helper in `stats.py` — same query as `top_countries_raw` but no `[:10]`
1437+
- [x] Add `GET /libraries/countries/` endpoint in `api.py`
1438+
- [x] Add cache header (1 hour — country list changes infrequently)
1439+
- [x] Add test: returns all countries with approved libraries
1440+
- [x] Add test: empty DB returns empty list

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## v1.4.0
4+
5+
- Country list endpoint (`GET /api/v1/libraries/countries/`) returns all countries with approved libraries and counts, ordered by count descending
6+
37
## v1.3.0
48

59
- Community photo endpoint (`POST /api/v1/libraries/{slug}/photo`) is now documented

docs/libraries/countries.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Countries
2+
3+
List all countries that have at least one approved library, with counts.
4+
5+
## `GET /api/v1/libraries/countries/`
6+
7+
Returns every country that contains at least one approved library, ordered by library count descending. Unlike the `/statistics/` endpoint (which caps at 10), this returns the full list.
8+
9+
**Authentication:** None required (public endpoint).
10+
11+
**Rate limiting:** Standard read rate limit applies.
12+
13+
**Caching:** Responses are cached for 1 hour (`Cache-Control: public, max-age=3600`).
14+
15+
### Response fields
16+
17+
| Field | Type | Description |
18+
|-------|------|-------------|
19+
| `items` | array | All countries with approved libraries |
20+
21+
Each **country** object contains:
22+
23+
| Field | Type | Description |
24+
|-------|------|-------------|
25+
| `country_code` | string | ISO 3166-1 alpha-2 code |
26+
| `country_name` | string | Human-readable country name |
27+
| `flag_emoji` | string | Unicode flag emoji |
28+
| `count` | integer | Number of approved libraries |
29+
30+
### Example request
31+
32+
=== "curl"
33+
34+
```bash
35+
curl https://bookcorners.org/api/v1/libraries/countries/
36+
```
37+
38+
=== "Python"
39+
40+
```python
41+
import httpx
42+
43+
response = httpx.get("https://bookcorners.org/api/v1/libraries/countries/")
44+
countries = response.json()["items"]
45+
for c in countries:
46+
print(f"{c['flag_emoji']} {c['country_name']}: {c['count']}")
47+
```
48+
49+
### Example response
50+
51+
```json
52+
{
53+
"items": [
54+
{
55+
"country_code": "DE",
56+
"country_name": "Germany",
57+
"flag_emoji": "\ud83c\udde9\ud83c\uddea",
58+
"count": 120
59+
},
60+
{
61+
"country_code": "FR",
62+
"country_name": "France",
63+
"flag_emoji": "\ud83c\uddeb\ud83c\uddf7",
64+
"count": 85
65+
},
66+
{
67+
"country_code": "IT",
68+
"country_name": "Italy",
69+
"flag_emoji": "\ud83c\uddee\ud83c\uddf9",
70+
"count": 42
71+
}
72+
]
73+
}
74+
```

docs/rate-limiting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ All windows are **5 minutes** (300 seconds).
88

99
| Tier | Endpoints | Max requests per window |
1010
|------|-----------|------------------------|
11-
| **Read** | `GET /libraries/`, `GET /libraries/latest`, `GET /libraries/{slug}`, `GET /statistics/` | 60 |
11+
| **Read** | `GET /libraries/`, `GET /libraries/latest`, `GET /libraries/{slug}`, `GET /libraries/countries/`, `GET /statistics/` | 60 |
1212
| **Write** | `POST /libraries/`, `POST /libraries/{slug}/report`, `POST /libraries/{slug}/photo` | 10 |
1313
| **Auth — Login** | `POST /auth/login` | 10 |
1414
| **Auth — Register** | `POST /auth/register` | 5 |

libraries/api.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from libraries.api_auth import get_optional_jwt_user
1414
from libraries.api_pagination import paginate_queryset
1515
from libraries.api_schemas import (
16+
CountryListOut,
1617
LatestLibrariesOut,
1718
LibraryListOut,
1819
LibraryOut,
@@ -25,7 +26,7 @@
2526
StatisticsOut,
2627
)
2728
from libraries.api_security import is_api_rate_limited
28-
from libraries.stats import build_stats_data
29+
from libraries.stats import build_stats_data, get_countries
2930
from libraries.forms import _validate_uploaded_photo
3031
from libraries.models import Library, LibraryPhoto, MAX_LIBRARY_PHOTOS_PER_USER, Report
3132
from libraries.notifications import notify_new_library, notify_new_photo, notify_new_report
@@ -93,6 +94,25 @@ def latest_libraries(
9394
return 200, {"items": list(queryset)}
9495

9596

97+
@library_router.get("/countries/", response={200: CountryListOut, 429: ErrorOut}, auth=None, summary="List all countries with libraries")
98+
def list_countries(request):
99+
"""Return all countries that have at least one approved library.
100+
Ordered by library count descending, without a top-N limit."""
101+
limited, retry_after = is_api_rate_limited(
102+
request=request,
103+
scope="api-library-countries",
104+
max_requests=settings.API_RATE_LIMIT_READ_REQUESTS,
105+
)
106+
if limited:
107+
return 429, ErrorOut(
108+
message="Too many requests. Please try again later.",
109+
details={"retry_after": retry_after},
110+
)
111+
112+
countries = get_countries()
113+
return 200, {"items": countries}
114+
115+
96116
@library_router.get("/{slug}", response={200: LibraryOut, 404: ErrorOut, 429: ErrorOut}, auth=None, summary="Get a library by slug")
97117
def get_library(request, slug: str):
98118
"""Return a single library by its slug.

libraries/api_schemas.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ class TimeSeriesPointOut(Schema):
199199
cumulative_count: int = Field(description="Running total of approved libraries up to this period.", examples=[128])
200200

201201

202+
class CountryListOut(Schema):
203+
"""List of all countries that have at least one approved library.
204+
Wraps country statistics in a flat items list."""
205+
206+
items: list[CountryStatOut] = Field(description="All countries with approved libraries, ordered by count descending.")
207+
208+
202209
class StatisticsOut(Schema):
203210
"""Aggregate platform statistics for approved libraries.
204211
Includes totals, geographic breakdown, and growth over time."""

libraries/middleware.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# Only applied to GET requests that return 2xx responses.
77
_API_CACHE_RULES = [
88
("/api/v1/statistics/", True, 900, 900),
9+
("/api/v1/libraries/countries/", True, 3600, 3600),
910
("/api/v1/libraries/latest", True, 300, 300),
1011
("/api/v1/libraries/", True, 120, 120),
1112
]

libraries/stats.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,30 @@ def country_code_to_flag_emoji(*, country_code: str) -> str:
2121
return "".join(chr(0x1F1E6 + ord(c) - ord("A")) for c in country_code.upper())
2222

2323

24+
def get_countries() -> list[dict]:
25+
"""Return all countries with at least one approved library.
26+
Ordered by count descending, without the top-10 limit."""
27+
approved_qs = Library.objects.filter(status=Library.Status.APPROVED)
28+
countries_raw = (
29+
approved_qs
30+
.values("country")
31+
.annotate(count=Count("id"))
32+
.order_by("-count")
33+
)
34+
countries = []
35+
for entry in countries_raw:
36+
code = entry["country"]
37+
py_country = pycountry.countries.get(alpha_2=code)
38+
country_name = py_country.name if py_country else code
39+
countries.append({
40+
"country_code": code,
41+
"country_name": country_name,
42+
"flag_emoji": country_code_to_flag_emoji(country_code=code),
43+
"count": entry["count"],
44+
})
45+
return countries
46+
47+
2448
def build_stats_data() -> dict:
2549
"""Compute aggregate statistics about approved libraries.
2650
Returns cached data with counts, country breakdown, and growth series."""

libraries/test_api_countries.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import pytest
2+
from django.contrib.auth import get_user_model
3+
from django.contrib.gis.geos import Point
4+
from django.test import override_settings
5+
6+
from libraries.models import Library
7+
8+
User = get_user_model()
9+
10+
11+
@pytest.fixture
12+
def countries_user(db):
13+
"""Create a user for countries endpoint test fixtures.
14+
Provides a baseline creator for library objects."""
15+
return User.objects.create_user(
16+
username="countriesuser",
17+
password="countriespass123",
18+
)
19+
20+
21+
def _create_library(*, user, country="IT", city="Florence", status=Library.Status.APPROVED):
22+
"""Create a library with minimal required fields.
23+
Reduces boilerplate across countries test cases."""
24+
return Library.objects.create(
25+
name=f"Lib in {city}",
26+
photo="libraries/photos/2026/02/test.jpg",
27+
location=Point(x=11.2558, y=43.7696, srid=4326),
28+
address="Via Rosina 15",
29+
city=city,
30+
country=country,
31+
status=status,
32+
created_by=user,
33+
)
34+
35+
36+
@pytest.mark.django_db
37+
class TestCountriesEndpoint:
38+
"""Tests for the GET /api/v1/libraries/countries/ endpoint."""
39+
40+
def test_returns_all_countries_with_approved_libraries(self, client, countries_user):
41+
"""Verify the endpoint returns every country that has approved libraries.
42+
Confirms no top-N limit is applied."""
43+
countries = ["IT", "DE", "FR", "ES", "GB", "US", "NL", "BE", "AT", "CH", "PT", "SE"]
44+
for code in countries:
45+
_create_library(user=countries_user, country=code, city=f"City-{code}")
46+
47+
response = client.get("/api/v1/libraries/countries/")
48+
49+
assert response.status_code == 200
50+
data = response.json()
51+
assert len(data["items"]) == 12
52+
53+
def test_empty_db_returns_empty_list(self, client, db):
54+
"""Verify the endpoint returns an empty items list when no libraries exist.
55+
Confirms graceful handling of the zero-data case."""
56+
response = client.get("/api/v1/libraries/countries/")
57+
58+
assert response.status_code == 200
59+
assert response.json()["items"] == []
60+
61+
def test_excludes_pending_and_rejected_libraries(self, client, countries_user):
62+
"""Verify only approved libraries contribute to country counts.
63+
Confirms non-approved statuses are filtered out."""
64+
_create_library(user=countries_user, country="IT", city="Florence")
65+
_create_library(user=countries_user, country="DE", city="Berlin", status=Library.Status.PENDING)
66+
_create_library(user=countries_user, country="FR", city="Paris", status=Library.Status.REJECTED)
67+
68+
response = client.get("/api/v1/libraries/countries/")
69+
70+
data = response.json()
71+
assert len(data["items"]) == 1
72+
assert data["items"][0]["country_code"] == "IT"
73+
74+
def test_ordered_by_count_descending(self, client, countries_user):
75+
"""Verify countries are ordered by library count, most first.
76+
Confirms the descending sort contract."""
77+
for _ in range(3):
78+
_create_library(user=countries_user, country="DE", city="Berlin")
79+
_create_library(user=countries_user, country="IT", city="Florence")
80+
81+
response = client.get("/api/v1/libraries/countries/")
82+
83+
items = response.json()["items"]
84+
assert items[0]["country_code"] == "DE"
85+
assert items[0]["count"] == 3
86+
assert items[1]["country_code"] == "IT"
87+
assert items[1]["count"] == 1
88+
89+
def test_country_entry_has_expected_fields(self, client, countries_user):
90+
"""Verify each country entry contains code, name, flag, and count.
91+
Confirms the schema contract for the country list."""
92+
_create_library(user=countries_user, country="FR", city="Paris")
93+
94+
response = client.get("/api/v1/libraries/countries/")
95+
96+
item = response.json()["items"][0]
97+
assert item["country_code"] == "FR"
98+
assert item["country_name"] == "France"
99+
assert item["flag_emoji"] == "\U0001F1EB\U0001F1F7"
100+
assert item["count"] == 1
101+
102+
def test_public_access_without_authentication(self, client, db):
103+
"""Verify the endpoint is publicly accessible without auth.
104+
Confirms no JWT or session is required."""
105+
response = client.get("/api/v1/libraries/countries/")
106+
107+
assert response.status_code == 200
108+
109+
@override_settings(API_RATE_LIMIT_ENABLED=True, API_RATE_LIMIT_READ_REQUESTS=1, API_RATE_LIMIT_WINDOW_SECONDS=60)
110+
def test_rate_limiting_returns_429(self, client, countries_user):
111+
"""Verify the endpoint enforces rate limiting.
112+
Confirms excessive requests receive a 429 response."""
113+
client.get("/api/v1/libraries/countries/")
114+
115+
response = client.get("/api/v1/libraries/countries/")
116+
117+
assert response.status_code == 429
118+
119+
def test_cache_control_header(self, client, countries_user):
120+
"""Verify GET /libraries/countries/ returns 1-hour public cache headers.
121+
Confirms the country list has a long TTL since it changes infrequently."""
122+
_create_library(user=countries_user, country="IT", city="Florence")
123+
124+
response = client.get("/api/v1/libraries/countries/")
125+
126+
assert response.status_code == 200
127+
cc = response["Cache-Control"]
128+
assert "public" in cc
129+
assert "max-age=3600" in cc
130+
assert "s-maxage=3600" in cc

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ nav:
4545
- Submit: libraries/submit.md
4646
- Report: libraries/report.md
4747
- Submit Photo: libraries/submit-photo.md
48+
- Countries: libraries/countries.md
4849
- Statistics: statistics.md
4950
- Errors: errors.md
5051
- Rate Limiting: rate-limiting.md

0 commit comments

Comments
 (0)