diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 908cda1..0006a50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,9 @@ jobs: - name: Run test suite run: uvx nox -s tests + - name: Validate OpenAPI schema + run: uvx nox -s validate_openapi + deploy: needs: tests if: github.ref == 'refs/heads/master' && github.event_name == 'push' diff --git a/book-corners-plan.md b/book-corners-plan.md index b70d051..21228dc 100644 --- a/book-corners-plan.md +++ b/book-corners-plan.md @@ -1239,22 +1239,22 @@ preparing the project for a larger dataset before adding another major surface a ##### 7.2.3 — API/docs parity and contract validation -- [ ] Fix docs drift where prose docs and actual behavior disagree -- [ ] Align search docs with the real search implementation (or expand implementation to match the docs) -- [ ] Update upload docs to reflect all accepted image formats -- [ ] Correct statistics docs and changelog entries to use the actual routed API paths -- [ ] Add a prose docs page for `POST /api/v1/libraries/{slug}/photo` -- [ ] Add OpenAPI schema validation in CI -- [ ] Add an end-to-end API integration test covering: - - [ ] register - - [ ] login - - [ ] submit library - - [ ] list - - [ ] search - - [ ] detail - - [ ] report - - [ ] community photo submission -- [ ] Regenerate `docs/openapi.json` whenever API code or API docs change +- [x] Fix docs drift where prose docs and actual behavior disagree +- [x] Align search docs with the real search implementation (or expand implementation to match the docs) +- [x] Update upload docs to reflect all accepted image formats +- [x] Correct statistics docs and changelog entries to use the actual routed API paths +- [x] Add a prose docs page for `POST /api/v1/libraries/{slug}/photo` +- [x] Add OpenAPI schema validation in CI +- [x] Add an end-to-end API integration test covering: + - [x] register + - [x] login + - [x] submit library + - [x] list + - [x] search + - [x] detail + - [x] report + - [x] community photo submission +- [x] Regenerate `docs/openapi.json` whenever API code or API docs change ##### 7.2.4 — Search quality and indexing diff --git a/docs/changelog.md b/docs/changelog.md index 35acef9..6c9d626 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,12 +1,20 @@ # Changelog +## v1.3.0 + +- Community photo endpoint (`POST /api/v1/libraries/{slug}/photo`) is now documented +- Fixed `q` search parameter description: searches name and description (not address) +- Fixed image format documentation: JPEG/PNG/WEBP accepted (not just JPEG/PNG) +- Fixed `GET /api/v1/statistics/` path in docs (was missing `/api/v1/` prefix) +- Added `POST /libraries/{slug}/photo` and `GET /statistics/` to rate-limiting documentation + ## v1.2.0 - Login endpoint (`POST /auth/login`) now accepts email address in the `username` field, matching the web login flow. Email lookup is case-insensitive and the identifier is trimmed before authentication. ## v1.1.0 -- Public statistics endpoint (`GET /statistics/`) with totals, top countries, and cumulative growth series +- Public statistics endpoint (`GET /api/v1/statistics/`) with totals, top countries, and cumulative growth series - Community photo submissions count towards the "libraries with photos" statistic ## v1.0.0 diff --git a/docs/errors.md b/docs/errors.md index 73ab639..1a4d333 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -23,7 +23,7 @@ All API errors return a consistent JSON structure, making it straightforward to | `400` | Bad Request | Invalid input (bad email, duplicate username, unsupported photo format) | | `401` | Unauthorized | Missing/invalid JWT token, or bad credentials on login | | `404` | Not Found | Library slug doesn't exist or isn't visible to the current user | -| `413` | Payload Too Large | Uploaded photo exceeds the size limit (8 MB for libraries, 5 MB for reports) | +| `413` | Payload Too Large | Uploaded photo exceeds the size limit (8 MB for libraries and community photos, 5 MB for reports) | | `422` | Unprocessable Entity | Request validation failed (missing required fields, out-of-range values) | | `429` | Too Many Requests | Rate limit exceeded (see [Rate Limiting](rate-limiting.md)) | | `500` | Internal Server Error | Unexpected server error | diff --git a/docs/getting-started.md b/docs/getting-started.md index d86ddff..485e36b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -101,3 +101,4 @@ Public endpoints like listing libraries don't require authentication: - [Authentication](authentication.md) — full token lifecycle details - [List & Search](libraries/list-and-search.md) — all search and filter options - [Submit a Library](libraries/submit.md) — add a new library with a photo +- [Submit a Community Photo](libraries/submit-photo.md) — add a photo to an existing library diff --git a/docs/index.md b/docs/index.md index 84ef73e..bc2e20f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,7 @@ https://bookcorners.org/api/v1/ | [List & Search](libraries/list-and-search.md) | Browse and search the library catalogue | | [Submit a Library](libraries/submit.md) | Add a new library with a photo | | [Report an Issue](libraries/report.md) | Flag problems with a library | +| [Submit a Community Photo](libraries/submit-photo.md) | Add a photo to an existing library | | [Statistics](statistics.md) | Platform-wide aggregate statistics | | [Errors](errors.md) | Error response format and status codes | | [Rate Limiting](rate-limiting.md) | Request limits and 429 handling | diff --git a/docs/libraries/list-and-search.md b/docs/libraries/list-and-search.md index e8e5163..0b47ddd 100644 --- a/docs/libraries/list-and-search.md +++ b/docs/libraries/list-and-search.md @@ -12,7 +12,7 @@ Return a paginated list of approved libraries with optional search filters. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `q` | string | — | Free-text search across name, description, and address (max 200 chars) | +| `q` | string | — | Free-text search across name and description (max 200 chars) | | `city` | string | — | Filter by city name (case-insensitive, max 100 chars) | | `country` | string | — | Filter by ISO 3166-1 alpha-2 country code (max 2 chars) | | `postal_code` | string | — | Filter by postal / ZIP code (max 20 chars) | diff --git a/docs/libraries/report.md b/docs/libraries/report.md index eb2ce61..dcdbefb 100644 --- a/docs/libraries/report.md +++ b/docs/libraries/report.md @@ -20,7 +20,7 @@ Report a problem with an approved library. Reports are reviewed by moderators. |-------|------|----------|-------------| | `reason` | string | Yes | Issue category (see values below) | | `details` | string | No | Free-text description of the issue (max 2000 chars) | -| `photo` | file | No | Photo showing the issue (JPEG/PNG, max 5 MB) | +| `photo` | file | No | Photo showing the issue (JPEG/PNG/WEBP, max 5 MB) | ### Reason values diff --git a/docs/libraries/submit-photo.md b/docs/libraries/submit-photo.md new file mode 100644 index 0000000..d822cab --- /dev/null +++ b/docs/libraries/submit-photo.md @@ -0,0 +1,91 @@ +# Submit a Community Photo + +`POST /api/v1/libraries/{slug}/photo` + +Submit a community photo for an approved library. The photo starts in **pending** status and must be approved by a moderator before it appears publicly. Each user can submit up to 3 photos per library. + +**Auth required:** Yes (`Bearer` token) + +**Content type:** `multipart/form-data` + +## Path parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `slug` | string | URL slug of the library to add a photo to | + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `photo` | file | Yes | Photo of the library (JPEG/PNG/WEBP, max 8 MB) | +| `caption` | string | No | Optional caption for the photo (max 200 chars) | + +## Examples + +=== "curl" + + ```bash + curl -X POST https://bookcorners.org/api/v1/libraries/berlin-friedrichstr-12-corner-books/photo \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \ + -F "photo=@community-photo.jpg" \ + -F "caption=Summer view from the park side" + ``` + +=== "Python" + + ```python + import requests + + resp = requests.post( + "https://bookcorners.org/api/v1/libraries/berlin-friedrichstr-12-corner-books/photo", + headers={"Authorization": f"Bearer {access_token}"}, + data={"caption": "Summer view from the park side"}, + files={"photo": open("community-photo.jpg", "rb")}, + ) + print(resp.json()) + ``` + +### Submit without a caption + +=== "curl" + + ```bash + curl -X POST https://bookcorners.org/api/v1/libraries/berlin-friedrichstr-12-corner-books/photo \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \ + -F "photo=@community-photo.jpg" + ``` + +=== "Python" + + ```python + resp = requests.post( + "https://bookcorners.org/api/v1/libraries/berlin-friedrichstr-12-corner-books/photo", + headers={"Authorization": f"Bearer {access_token}"}, + files={"photo": open("community-photo.jpg", "rb")}, + ) + ``` + +## Response (`201 Created`) + +```json +{ + "id": 15, + "caption": "Summer view from the park side", + "status": "pending", + "created_at": "2025-06-15T14:30:00Z" +} +``` + +!!! note + The photo will have **pending** status. It won't appear on the library detail page until approved by a moderator. Each user can submit up to 3 photos per library (rejected photos do not count towards this limit). + +## Errors + +| Status | Cause | +|--------|-------| +| `400` | Invalid photo format, or per-user limit of 3 photos reached for this library | +| `404` | Library not found or not in approved status | +| `413` | Photo exceeds 8 MB size limit | +| `422` | Request validation error | +| `429` | Rate limit exceeded (see [Rate Limiting](../rate-limiting.md)) | diff --git a/docs/libraries/submit.md b/docs/libraries/submit.md index 7e331d3..21c1e24 100644 --- a/docs/libraries/submit.md +++ b/docs/libraries/submit.md @@ -28,7 +28,7 @@ Submit a new library location with a photo. The library starts in **pending** st | `brand` | string | No | Network or brand name (max 255 chars) | | `latitude` | float | Yes | Latitude (-90 to 90, WGS 84) | | `longitude` | float | Yes | Longitude (-180 to 180, WGS 84) | -| `photo` | file | Yes | Photo of the library (JPEG/PNG, max 8 MB) | +| `photo` | file | Yes | Photo of the library (JPEG/PNG/WEBP, max 8 MB) | ## Examples diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md index 072f1ba..e7df14e 100644 --- a/docs/rate-limiting.md +++ b/docs/rate-limiting.md @@ -8,8 +8,8 @@ All windows are **5 minutes** (300 seconds). | Tier | Endpoints | Max requests per window | |------|-----------|------------------------| -| **Read** | `GET /libraries/`, `GET /libraries/latest`, `GET /libraries/{slug}` | 60 | -| **Write** | `POST /libraries/`, `POST /libraries/{slug}/report` | 10 | +| **Read** | `GET /libraries/`, `GET /libraries/latest`, `GET /libraries/{slug}`, `GET /statistics/` | 60 | +| **Write** | `POST /libraries/`, `POST /libraries/{slug}/report`, `POST /libraries/{slug}/photo` | 10 | | **Auth — Login** | `POST /auth/login` | 10 | | **Auth — Register** | `POST /auth/register` | 5 | | **Auth — Refresh** | `POST /auth/refresh` | 15 | diff --git a/docs/statistics.md b/docs/statistics.md index 43730d8..2ddf06d 100644 --- a/docs/statistics.md +++ b/docs/statistics.md @@ -2,7 +2,7 @@ Public, read-only endpoint that returns aggregate statistics about approved libraries on the platform. -## `GET /statistics/` +## `GET /api/v1/statistics/` Returns totals, a top-10 country ranking, and a cumulative growth time series. diff --git a/libraries/api_schemas.py b/libraries/api_schemas.py index 5c85de4..32702c7 100644 --- a/libraries/api_schemas.py +++ b/libraries/api_schemas.py @@ -102,16 +102,16 @@ class LibrarySearchParams(Schema): """Query parameters for searching and filtering libraries. Validates bounds and defaults for pagination and geospatial queries.""" - q: str | None = Field(default=None, max_length=200, description="Free-text search query matched against name, description, and address.", examples=["corner books"]) - city: str | None = Field(default=None, max_length=100, description="Filter by exact city name (case-insensitive).", examples=["Berlin"]) - country: str | None = Field(default=None, max_length=2, description="Filter by ISO 3166-1 alpha-2 country code.", examples=["DE"]) - postal_code: str | None = Field(default=None, max_length=20, description="Filter by postal or ZIP code.", examples=["10117"]) - lat: float | None = Field(default=None, ge=-90, le=90, description="Latitude for proximity search (requires lng and radius_km).", examples=[52.5200]) - lng: float | None = Field(default=None, ge=-180, le=180, description="Longitude for proximity search (requires lat and radius_km).", examples=[13.4050]) - radius_km: int | None = Field(default=None, ge=1, le=100, description="Search radius in kilometres (requires lat and lng).", examples=[5]) + q: str | None = Field(default=None, max_length=200, description="Free-text search query matched against name and description.", json_schema_extra={"example": "corner books"}) + city: str | None = Field(default=None, max_length=100, description="Filter by exact city name (case-insensitive).", json_schema_extra={"example": "Berlin"}) + country: str | None = Field(default=None, max_length=2, description="Filter by ISO 3166-1 alpha-2 country code.", json_schema_extra={"example": "DE"}) + postal_code: str | None = Field(default=None, max_length=20, description="Filter by postal or ZIP code.", json_schema_extra={"example": "10117"}) + lat: float | None = Field(default=None, ge=-90, le=90, description="Latitude for proximity search (requires lng and radius_km).", json_schema_extra={"example": 52.52}) + lng: float | None = Field(default=None, ge=-180, le=180, description="Longitude for proximity search (requires lat and radius_km).", json_schema_extra={"example": 13.405}) + radius_km: int | None = Field(default=None, ge=1, le=100, description="Search radius in kilometres (requires lat and lng).", json_schema_extra={"example": 5}) has_photo: bool | None = Field(default=None, description="Filter by photo presence: true for libraries with a photo, false for those without.") - page: int = Field(default=1, ge=1, le=1000, description="Page number to retrieve (1-indexed).", examples=[1]) - page_size: int = Field(default=20, ge=1, le=50, description="Number of items per page.", examples=[20]) + page: int = Field(default=1, ge=1, le=1000, description="Page number to retrieve (1-indexed).", json_schema_extra={"example": 1}) + page_size: int = Field(default=20, ge=1, le=50, description="Number of items per page.", json_schema_extra={"example": 20}) class LibrarySubmitIn(Schema): diff --git a/libraries/test_api_integration.py b/libraries/test_api_integration.py new file mode 100644 index 0000000..399aab8 --- /dev/null +++ b/libraries/test_api_integration.py @@ -0,0 +1,167 @@ +import json + +import pytest +from django.core.cache import cache + +from libraries.models import Library +from libraries.tests import _build_uploaded_photo + + +@pytest.mark.django_db +class TestFullApiWorkflow: + """End-to-end integration test covering the full API user journey. + Exercises register, login, submit, list, search, detail, report, photo, and statistics.""" + + def setup_method(self): + """Clear the cache before each test. + Prevents rate limit state from leaking between tests.""" + cache.clear() + + def test_full_user_workflow(self, client, tmp_path, settings): + """Walk through the entire API lifecycle from registration to statistics. + Verifies that all endpoints work together as a cohesive workflow.""" + settings.MEDIA_ROOT = tmp_path + settings.API_RATE_LIMIT_ENABLED = False + settings.AUTH_RATE_LIMIT_ENABLED = False + + # 1. Register + register_response = client.post( + "/api/v1/auth/register", + data=json.dumps({ + "username": "integration_user", + "email": "integration@example.com", + "password": "SecurePass123!", + }), + content_type="application/json", + ) + assert register_response.status_code == 201 + tokens = register_response.json() + assert "access" in tokens + assert "refresh" in tokens + access_token = tokens["access"] + + # 2. Login + login_response = client.post( + "/api/v1/auth/login", + data=json.dumps({ + "username": "integration_user", + "password": "SecurePass123!", + }), + content_type="application/json", + ) + assert login_response.status_code == 200 + login_tokens = login_response.json() + assert "access" in login_tokens + access_token = login_tokens["access"] + + # 3. Me + me_response = client.get( + "/api/v1/auth/me", + HTTP_AUTHORIZATION=f"Bearer {access_token}", + ) + assert me_response.status_code == 200 + me_data = me_response.json() + assert me_data["username"] == "integration_user" + assert me_data["email"] == "integration@example.com" + + # 4. Submit library + photo = _build_uploaded_photo() + submit_response = client.post( + "/api/v1/libraries/", + data={ + "name": "Integration Test Library", + "description": "A library created during integration testing.", + "address": "42 Test Street", + "city": "Berlin", + "country": "DE", + "postal_code": "10115", + "latitude": "52.5200", + "longitude": "13.4050", + "photo": photo, + }, + HTTP_AUTHORIZATION=f"Bearer {access_token}", + ) + assert submit_response.status_code == 201 + library_data = submit_response.json() + slug = library_data["slug"] + assert slug + assert library_data["name"] == "Integration Test Library" + assert library_data["city"] == "Berlin" + + # Verify pending status in DB + library = Library.objects.get(slug=slug) + assert library.status == Library.Status.PENDING + + # 5. Detail (own pending) — owner can see their pending library + detail_pending_response = client.get( + f"/api/v1/libraries/{slug}", + HTTP_AUTHORIZATION=f"Bearer {access_token}", + ) + assert detail_pending_response.status_code == 200 + assert detail_pending_response.json()["slug"] == slug + + # Verify anonymous user cannot see pending library + detail_anonymous_response = client.get(f"/api/v1/libraries/{slug}") + assert detail_anonymous_response.status_code == 404 + + # 6. Approve library (simulates moderation via ORM) + library.status = Library.Status.APPROVED + library.save() + + # 7. List — approved library appears in listing + list_response = client.get("/api/v1/libraries/") + assert list_response.status_code == 200 + list_data = list_response.json() + slugs = [item["slug"] for item in list_data["items"]] + assert slug in slugs + assert "pagination" in list_data + + # 8. Search — library found by name + search_response = client.get( + "/api/v1/libraries/", + data={"q": "Integration Test"}, + ) + assert search_response.status_code == 200 + search_slugs = [item["slug"] for item in search_response.json()["items"]] + assert slug in search_slugs + + # 9. Detail (public) — approved library visible without auth + detail_public_response = client.get(f"/api/v1/libraries/{slug}") + assert detail_public_response.status_code == 200 + assert detail_public_response.json()["slug"] == slug + + # 10. Report + report_response = client.post( + f"/api/v1/libraries/{slug}/report", + data={ + "reason": "incorrect_info", + "details": "The address is slightly wrong.", + }, + HTTP_AUTHORIZATION=f"Bearer {access_token}", + ) + assert report_response.status_code == 201 + report_data = report_response.json() + assert report_data["reason"] == "incorrect_info" + assert "id" in report_data + + # 11. Community photo + community_photo = _build_uploaded_photo() + photo_response = client.post( + f"/api/v1/libraries/{slug}/photo", + data={ + "caption": "A nice angle from the street", + "photo": community_photo, + }, + HTTP_AUTHORIZATION=f"Bearer {access_token}", + ) + assert photo_response.status_code == 201 + photo_data = photo_response.json() + assert photo_data["status"] == "pending" + assert photo_data["caption"] == "A nice angle from the street" + assert "id" in photo_data + + # 12. Statistics + statistics_response = client.get("/api/v1/statistics/") + assert statistics_response.status_code == 200 + stats = statistics_response.json() + assert stats["total_approved"] >= 1 diff --git a/mkdocs.yml b/mkdocs.yml index 9faf47c..cdac38d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,7 @@ nav: - Detail: libraries/detail.md - Submit: libraries/submit.md - Report: libraries/report.md + - Submit Photo: libraries/submit-photo.md - Statistics: statistics.md - Errors: errors.md - Rate Limiting: rate-limiting.md diff --git a/noxfile.py b/noxfile.py index 44a585f..060f675 100644 --- a/noxfile.py +++ b/noxfile.py @@ -11,3 +11,24 @@ def tests(session: nox.Session) -> None: """Run the test suite.""" session.install("-r", "requirements.txt") session.run("pytest", *session.posargs) + + +@nox.session(python=PYTHON_VERSIONS) +def validate_openapi(session: nox.Session) -> None: + """Validate the generated OpenAPI schema against the specification.""" + session.install("-r", "requirements.txt") + session.run("python", "manage.py", "migrate", "--run-syncdb") + session.run( + "bash", + "-c", + "python manage.py export_openapi_schema > /tmp/openapi.json", + external=True, + ) + session.run( + "python", + "-c", + "from openapi_spec_validator import validate; " + "import json; " + "validate(json.load(open('/tmp/openapi.json'))); " + "print('OpenAPI schema is valid.')", + ) diff --git a/requirements.txt b/requirements.txt index 777879c..51d7476 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,4 +18,5 @@ sqlparse==0.5.5 structlog==25.5.0 sentry-sdk[django]==2.54.0 openai==2.24.0 +openapi-spec-validator==0.7.1 whitenoise==6.12.0