Feat: Replace UUID image identifiers with 8-character base62 short IDs
Short IDs become the canonical identifier in URLs (/i/:short_id), MinIO/R2 storage keys, and all API responses. Hash-based deduplication is preserved. Includes two-phase Alembic migration (003 adds nullable column, 004 enforces NOT NULL) with a backfill script to copy storage objects and populate short_id for existing images. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
115
specs/017-short-id-migration/contracts/image-api.md
Normal file
115
specs/017-short-id-migration/contracts/image-api.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Contract: Image API (Short ID Update)
|
||||
|
||||
## ImageRecord Response Schema
|
||||
|
||||
All image endpoints return this shape. `short_id` is a new field; all other fields are unchanged.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "7343d164-80bb-473b-b239-717f2842ae4e",
|
||||
"short_id": "xK7mN2pQ",
|
||||
"hash": "163dec08460650439f1e7439721e8e566aff7d8aaad60cf451e7d3518a334a23",
|
||||
"filename": "image.gif",
|
||||
"mime_type": "image/gif",
|
||||
"size_bytes": 1957149,
|
||||
"width": 265,
|
||||
"height": 199,
|
||||
"storage_key": "xK7mN2pQ",
|
||||
"thumbnail_key": "xK7mN2pQ-thumb",
|
||||
"file_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ",
|
||||
"thumbnail_url": "https://cdn.reactbin.juggalol.com/xK7mN2pQ-thumb",
|
||||
"created_at": "2026-05-09T02:46:29.520296+00:00",
|
||||
"tags": ["kfc"]
|
||||
}
|
||||
```
|
||||
|
||||
**Constraints**:
|
||||
- `short_id`: exactly 8 alphanumeric characters `[a-zA-Z0-9]{8}`
|
||||
- `storage_key`: equals `short_id` (post-migration)
|
||||
- `thumbnail_key`: equals `{short_id}-thumb` or `null` if no thumbnail exists
|
||||
- `file_url`: `{cdn_base}/{short_id}` when CDN is configured; `/api/v1/images/{short_id}/file` otherwise
|
||||
- `thumbnail_url`: `{cdn_base}/{short_id}-thumb` or `null`
|
||||
|
||||
---
|
||||
|
||||
## Route Changes
|
||||
|
||||
All routes that previously accepted `{image_id}` as a UUID now accept `{short_id}` as an 8-character alphanumeric string.
|
||||
|
||||
### GET /api/v1/images/{short_id}
|
||||
|
||||
Fetch a single image by short ID.
|
||||
|
||||
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||
- **Response 200**: ImageRecord
|
||||
- **Response 404**: `{"detail": "Image not found", "code": "image_not_found"}`
|
||||
- **Response 422**: `{"detail": "Invalid image ID", "code": "invalid_short_id"}` if param is not 8 alphanumeric chars
|
||||
|
||||
### PATCH /api/v1/images/{short_id}/tags
|
||||
|
||||
Update tags on an image. Auth required.
|
||||
|
||||
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||
- **Body**: `{"tags": ["tag1", "tag2"]}`
|
||||
- **Response 200**: ImageRecord (updated)
|
||||
- **Response 404/422**: same shape as above
|
||||
|
||||
### DELETE /api/v1/images/{short_id}
|
||||
|
||||
Delete an image and its storage objects. Auth required.
|
||||
|
||||
- **Path param**: `short_id` — 8-char alphanumeric string
|
||||
- **Response 204**: no body
|
||||
- **Response 404**: error envelope
|
||||
|
||||
### GET /api/v1/images/{short_id}/file
|
||||
|
||||
Serve the raw image file (proxy mode, when CDN is not configured).
|
||||
|
||||
- **Path param**: `short_id`
|
||||
- **Response 200**: raw image bytes with correct `Content-Type`
|
||||
|
||||
### GET /api/v1/images/{short_id}/thumbnail
|
||||
|
||||
Serve the thumbnail (proxy mode).
|
||||
|
||||
- **Path param**: `short_id`
|
||||
- **Response 200**: WebP bytes or original image if no thumbnail
|
||||
|
||||
### POST /api/v1/images (upload — unchanged route, updated response)
|
||||
|
||||
- **Response**: ImageRecord with `short_id` populated
|
||||
|
||||
---
|
||||
|
||||
## Frontend Route Change
|
||||
|
||||
| Old route | New route |
|
||||
|-----------------|--------------|
|
||||
| `/images/:id` | `/i/:id` |
|
||||
|
||||
The `:id` segment now contains the `short_id` value (8 alphanumeric chars) rather than a UUID.
|
||||
|
||||
---
|
||||
|
||||
## ImageRecord TypeScript Interface (updated)
|
||||
|
||||
```typescript
|
||||
export interface ImageRecord {
|
||||
id: string; // UUID — retained, not used for routing
|
||||
short_id: string; // NEW — 8-char base62, used for all routing and API calls
|
||||
hash: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
storage_key: string;
|
||||
thumbnail_key: string | null;
|
||||
file_url: string;
|
||||
thumbnail_url: string | null;
|
||||
created_at: string;
|
||||
tags: string[];
|
||||
duplicate?: boolean;
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user