Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
32 changes: 16 additions & 16 deletions book-corners-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion docs/changelog.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/libraries/list-and-search.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion docs/libraries/report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
91 changes: 91 additions & 0 deletions docs/libraries/submit-photo.md
Original file line number Diff line number Diff line change
@@ -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)) |
2 changes: 1 addition & 1 deletion docs/libraries/submit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/rate-limiting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/statistics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 9 additions & 9 deletions libraries/api_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading