# 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": "", "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 |