# 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 |
