[Spec Kit] Initial commit — constitution, spec, plan, and tasks for Reaction Image Board v1

This commit is contained in:
2026-05-02 15:56:39 +00:00
commit 691f7570fe
46 changed files with 6149 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
# 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 |