217 lines
4.9 KiB
Markdown
217 lines
4.9 KiB
Markdown
# API Contract: Reaction Image Board v1
|
|
|
|
**Base URL**: `/api/v1`
|
|
**Format**: JSON (application/json) for all request/response bodies except
|
|
file uploads (multipart/form-data)
|
|
**Auth**: None in v1 (NoOpAuthProvider)
|
|
**Error envelope** (all 4xx/5xx responses):
|
|
```json
|
|
{ "detail": "<human-readable message>", "code": "<machine-readable code>" }
|
|
```
|
|
|
|
---
|
|
|
|
## Image Resource
|
|
|
|
### Image object shape
|
|
|
|
```json
|
|
{
|
|
"id": "uuid",
|
|
"hash": "sha256-hex-string-64-chars",
|
|
"filename": "original.jpg",
|
|
"mime_type": "image/jpeg",
|
|
"size_bytes": 102400,
|
|
"width": 800,
|
|
"height": 600,
|
|
"storage_key": "sha256-hex-string-64-chars",
|
|
"created_at": "2026-05-01T12:00:00Z",
|
|
"tags": ["cat", "funny"],
|
|
"duplicate": false
|
|
}
|
|
```
|
|
|
|
`duplicate` is only present on POST responses; omit or set to `false` on GET.
|
|
|
|
---
|
|
|
|
### POST /api/v1/images — Upload Image
|
|
|
|
**Request**: `Content-Type: multipart/form-data`
|
|
|
|
| Field | Required | Type | Notes |
|
|
|---|---|---|---|
|
|
| `file` | yes | file | The image binary |
|
|
| `tags` | no | string | Comma-separated, e.g. `"cat,funny,reaction"` |
|
|
|
|
**Processing**:
|
|
1. Validate MIME type → 422 `invalid_mime_type` if not accepted
|
|
2. Validate file size ≤ MAX_UPLOAD_BYTES → 422 `file_too_large` if exceeded
|
|
3. Compute SHA-256 hash of raw bytes
|
|
4. Query DB for existing record with same hash
|
|
5. If duplicate: return existing record, `duplicate: true`, HTTP 200
|
|
6. If new: write to storage, insert record, upsert tags, HTTP 201
|
|
|
|
**Responses**:
|
|
|
|
| Status | Condition | Body |
|
|
|---|---|---|
|
|
| 201 | New image stored | Image object with `duplicate: false` |
|
|
| 200 | Duplicate detected | Image object with `duplicate: true` |
|
|
| 422 | Invalid MIME type | `{"detail":"...", "code":"invalid_mime_type"}` |
|
|
| 422 | File too large | `{"detail":"...", "code":"file_too_large"}` |
|
|
| 422 | Invalid tag | `{"detail":"...", "code":"invalid_tag"}` |
|
|
|
|
---
|
|
|
|
### GET /api/v1/images — List / Search Images
|
|
|
|
**Query parameters**:
|
|
|
|
| Param | Type | Default | Notes |
|
|
|---|---|---|---|
|
|
| `tags` | string | — | Comma-separated. AND logic — all tags must be present. |
|
|
| `limit` | integer | 50 | Max 100 |
|
|
| `offset` | integer | 0 | Pagination offset |
|
|
|
|
**Response** 200:
|
|
```json
|
|
{
|
|
"items": [ { ...image object... } ],
|
|
"total": 142,
|
|
"limit": 50,
|
|
"offset": 0
|
|
}
|
|
```
|
|
|
|
Each item includes the `tags` array. `duplicate` field omitted.
|
|
Results ordered by `created_at` descending.
|
|
|
|
---
|
|
|
|
### GET /api/v1/images/{id} — Get Single Image
|
|
|
|
**Path**: `{id}` is a UUID.
|
|
|
|
**Response** 200: Single image object (tags included, `duplicate` omitted).
|
|
|
|
| Status | Code | Condition |
|
|
|---|---|---|
|
|
| 404 | `image_not_found` | No image with that UUID |
|
|
|
|
---
|
|
|
|
### GET /api/v1/images/{id}/file — Serve Image File
|
|
|
|
**Path**: `{id}` is a UUID.
|
|
|
|
**Response** 302: Redirect to pre-signed S3 URL (1-hour expiry).
|
|
`Location` header contains the URL.
|
|
|
|
| Status | Code | Condition |
|
|
|---|---|---|
|
|
| 404 | `image_not_found` | No image with that UUID |
|
|
|
|
---
|
|
|
|
### PATCH /api/v1/images/{id}/tags — Update Tags
|
|
|
|
**Request**: `Content-Type: application/json`
|
|
```json
|
|
{ "tags": ["cat", "funny", "new-tag"] }
|
|
```
|
|
|
|
Replaces the full tag set. Empty array removes all tags from the image.
|
|
Tag records themselves are never deleted.
|
|
|
|
**Response** 200: Full image object with updated tags.
|
|
|
|
| Status | Code | Condition |
|
|
|---|---|---|
|
|
| 404 | `image_not_found` | No image with that UUID |
|
|
| 422 | `invalid_tag` | A tag fails validation |
|
|
|
|
---
|
|
|
|
### DELETE /api/v1/images/{id} — Delete Image
|
|
|
|
**Path**: `{id}` is a UUID.
|
|
|
|
**Processing**:
|
|
1. Look up image record
|
|
2. Delete all `image_tags` rows for this image
|
|
3. Delete the image record
|
|
4. Delete the S3 object at `storage_key`
|
|
|
|
**Response** 204: No body.
|
|
|
|
| Status | Code | Condition |
|
|
|---|---|---|
|
|
| 404 | `image_not_found` | No image with that UUID |
|
|
|
|
---
|
|
|
|
## Tag Resource
|
|
|
|
### Tag object shape
|
|
|
|
```json
|
|
{
|
|
"id": "uuid",
|
|
"name": "cat",
|
|
"image_count": 42
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### GET /api/v1/tags — List Tags
|
|
|
|
**Query parameters**:
|
|
|
|
| Param | Type | Default | Notes |
|
|
|---|---|---|---|
|
|
| `q` | string | — | Prefix filter on tag name |
|
|
| `limit` | integer | 100 | Max 200 |
|
|
| `offset` | integer | 0 | Pagination offset |
|
|
|
|
**Response** 200:
|
|
```json
|
|
{
|
|
"items": [ { "id": "uuid", "name": "cat", "image_count": 42 } ],
|
|
"total": 7,
|
|
"limit": 100,
|
|
"offset": 0
|
|
}
|
|
```
|
|
|
|
Results ordered alphabetically by `name`.
|
|
|
|
---
|
|
|
|
## Health
|
|
|
|
### GET /api/v1/health
|
|
|
|
**Response** 200:
|
|
```json
|
|
{ "status": "ok" }
|
|
```
|
|
|
|
No auth or error cases.
|
|
|
|
---
|
|
|
|
## Constraints Summary
|
|
|
|
| Constraint | Value |
|
|
|---|---|
|
|
| Accepted MIME types | `image/jpeg`, `image/png`, `image/gif`, `image/webp` |
|
|
| Max upload size | 52 428 800 bytes (50 MiB); configurable via `MAX_UPLOAD_BYTES` env var |
|
|
| Tag name pattern | `^[a-z0-9_-]{1,64}$` (after normalisation) |
|
|
| Pre-signed URL expiry | 3 600 seconds (1 hour) |
|
|
| Max `limit` for images list | 100 |
|
|
| Max `limit` for tags list | 200 |
|
|
| Default `limit` for images list | 50 |
|
|
| Default `limit` for tags list | 100 |
|