17 KiB
Property Image Tagging REST API - Cursor AI Implementation Guide
Project Overview
Build a production-ready REST API for automatic property image tagging using Claude AI (Anthropic). The system uses Node.js, Express, MySQL, and Clean Architecture patterns.
Core Requirements
1. Technology Stack
- Runtime: Node.js 18+
- Framework: Express.js
- Database: MySQL 8.0+
- AI Provider: Anthropic Claude API (Claude Sonnet 4.5)
- Architecture: Clean Architecture (Domain, Application, Infrastructure, Presentation layers)
- Image Processing: Sharp library for format conversion and optimization
- Authentication: Simple API key system (no rate limiting, no usage tracking)
2. Key Features
- Upload property images and get 20-30 AI-generated tags
- Duplicate detection using SHA256 image hashing (cache results to avoid re-tagging)
- Support multiple image formats (JPEG, PNG, WebP, HEIC, TIFF, BMP, GIF)
- Search images by tags using MySQL JSON queries
- Simple API key authentication (SHA256 hashed)
- RESTful API with Clean Architecture
3. Architecture Principles
- Clean Architecture: Strict separation of Domain, Application, Infrastructure, Presentation
- Dependency Injection: Use container pattern for service management
- Repository Pattern: Abstract database operations
- Use Case Pattern: Each business operation is a separate use case class
- DTO Pattern: Data Transfer Objects for API boundaries
Project Structure
Create this exact structure:
src/
├── domain/
│ ├── entities/
│ │ ├── ImageTag.js
│ │ └── TaggingResult.js
│ └── interfaces/
│ ├── IImageTaggingService.js
│ └── IImageRepository.js
├── application/
│ ├── dtos/
│ │ ├── TagImageRequestDto.js
│ │ └── TagImageResponseDto.js
│ └── useCases/
│ ├── TagImageUseCase.js
│ └── TagBase64ImageUseCase.js
├── infrastructure/
│ ├── ai/
│ │ └── ClaudeAIProvider.js
│ ├── repositories/
│ │ ├── MySQLImageRepository.js
│ │ └── ApiKeyRepository.js
│ └── config/
│ ├── dependencyContainer.js
│ └── corsConfig.js
├── presentation/
│ ├── controllers/
│ │ └── ImageTaggingController.js
│ ├── middleware/
│ │ ├── errorHandler.js
│ │ ├── apiKeyAuth.js
│ │ └── requestId.js
│ ├── routes/
│ │ └── imageRoutes.js
│ └── validators/
│ └── imageValidator.js
├── shared/
│ ├── errors/
│ │ └── AppError.js
│ └── utils/
│ ├── logger.js
│ ├── responseFormatter.js
│ └── apiKeyGenerator.js
└── server.js
scripts/
└── manage-api-keys.js
migrations/
└── 001_initial_schema.sql
Database Schema
Table: images
CREATE TABLE images (
id CHAR(36) PRIMARY KEY,
file_name VARCHAR(255) NOT NULL,
original_name VARCHAR(255),
file_size INT UNSIGNED,
mime_type VARCHAR(50),
width INT UNSIGNED,
height INT UNSIGNED,
image_hash VARCHAR(64) UNIQUE NOT NULL,
s3_key VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_image_hash (image_hash)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Table: tagging_results
CREATE TABLE tagging_results (
id CHAR(36) PRIMARY KEY,
image_id CHAR(36) NOT NULL,
tags JSON NOT NULL,
summary TEXT,
total_tags INT UNSIGNED,
model_version VARCHAR(50) DEFAULT 'claude-sonnet-4-20250514',
processing_time_ms INT UNSIGNED,
was_duplicate BOOLEAN DEFAULT FALSE,
tagged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE,
INDEX idx_image_id (image_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Table: api_keys
CREATE TABLE api_keys (
id CHAR(36) PRIMARY KEY,
key_prefix VARCHAR(20) NOT NULL,
key_hash VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
environment ENUM('development', 'staging', 'production') DEFAULT 'development',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
revoked_reason TEXT,
INDEX idx_key_hash (key_hash),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Implementation Requirements
Domain Layer
ImageTag Entity:
- Properties: category, value, confidence
- Method: isHighConfidence() - returns true if confidence >= 0.8
- Method: toJSON() - serialize to plain object
- Validate category and value are not empty
TaggingResult Entity:
- Properties: imageId, tags array, summary, createdAt
- Methods:
- getTagsByCategory(category)
- getHighConfidenceTags()
- getTotalTags()
- Validate tags array is not empty
Interfaces:
- IImageTaggingService: generateTags(base64Image, mediaType)
- IImageRepository: save(taggingResult, imageBuffer), findById(imageId), findByImageHash(hash)
Application Layer
TagImageUseCase:
- Validate input (fileBuffer, mimeType required)
- Calculate SHA256 hash of image buffer
- Check database for existing hash
- If duplicate → return cached result with isDuplicate: true, costSavings message
- If new → convert to base64, call AI service
- Create TaggingResult entity
- Save to database with image buffer (for hash)
- Return TagImageResponseDto
TagImageRequestDto:
- fileBuffer (Buffer)
- mimeType (string)
- fileName (string)
- Method: toBase64() - convert buffer to base64 string
TagImageResponseDto:
- imageId, tags, summary, totalTags, isDuplicate, cachedResult, processedAt
- Static method: fromTaggingResult(taggingResult)
Infrastructure Layer
ClaudeAIProvider:
- Use @anthropic-ai/sdk npm package
- Model: claude-sonnet-4-20250514
- Wrap API call with async-retry (3 retries, exponential backoff)
- Send this exact prompt:
Analyze this property image and generate 20-30 descriptive tags categorized as follows:
Tag Categories:
1. View: (e.g., Burj Khalifa view, ocean view, downtown skyline, marina view)
2. Furnishing: (e.g., fully furnished, unfurnished, modern, contemporary, luxury)
3. Kitchen: (e.g., with appliances, open kitchen, modular, closed kitchen)
4. Flooring: (e.g., wooden, marble, tile, carpet, laminate, porcelain)
5. Room Type: (e.g., bedroom, living room, bathroom, kitchen, balcony)
6. Style: (e.g., modern, traditional, scandinavian, industrial)
7. Features: (e.g., high ceiling, floor-to-ceiling windows, built-in wardrobes)
8. Condition: (e.g., newly renovated, well-maintained, ready to move)
9. Lighting: (e.g., natural light, ambient lighting, LED lighting)
10. Color Scheme: (e.g., neutral tones, warm colors, monochrome)
Return ONLY a JSON object in this exact format:
{
"tags": [
{"category": "View", "value": "marina view", "confidence": 0.95},
{"category": "Furnishing", "value": "fully furnished", "confidence": 0.90}
],
"summary": "Brief one-sentence description"
}
Include 20-30 tags total.
MySQLImageRepository:
- Use mysql2/promise with connection pool (max 20 connections)
- _calculateImageHash(buffer) - SHA256 hash using crypto
- findByImageHash(hash) - returns existing TaggingResult if found
- save(taggingResult, imageBuffer) - stores both image and tags in transaction
- Calculate hash
- Check for duplicate first
- If duplicate, return cached result
- Insert into images table
- Insert into tagging_results table
- Commit transaction
- findById(imageId) - returns TaggingResult
- findByTagValue(value) - use JSON_SEARCH for MySQL
- Support JSON queries using JSON_CONTAINS and JSON_SEARCH
ApiKeyRepository:
- validateKey(apiKey) - hash key, check database, return key data if valid
- createKey(data) - generate secure key with ApiKeyGenerator, hash it, store
- revokeKey(keyId, reason) - set is_active=false
- getAllKeys() - list all keys
Presentation Layer
ImageValidator:
- validateUpload(file) - async function
- Check file exists
- Check mime type in allowed list
- Use file-type npm package to verify actual type (magic number)
- Use Sharp to validate image and check dimensions (max 15000px)
- Max file size 50MB
- convertToClaudeSupportedFormat(buffer, mimeType)
- If already JPEG/PNG/WebP/GIF → optimize only
- If HEIC/TIFF/BMP → convert to JPEG
- Use Sharp for conversion
- optimizeForAI(buffer) - resize to max 2048px if larger
ApiKeyAuthMiddleware:
- authenticate() middleware function
- Skip if SKIP_AUTH=true in development
- Extract key from X-API-Key header or Authorization: Bearer
- Validate key format: key_(test|live)_[64 hex chars]
- Call ApiKeyRepository.validateKey()
- If invalid → 401/403 with clear error
- If valid → attach req.apiKey = {id, name, environment}
- Log authentication
ImageTaggingController:
- tagUploadedImage(req, res, next)
- Validate upload
- Convert format if needed
- Create TagImageRequestDto
- Execute TagImageUseCase
- Return formatted response
- tagBase64Image(req, res, next)
- Validate base64 input with Joi
- Execute TagBase64ImageUseCase
- Return formatted response
- searchByTag(req, res, next)
- Get tag value from query
- Call repository.findByTagValue()
- Return results
- getStats(req, res, next)
- Get statistics from repository
- Return stats
- getHealth(req, res)
- Check database connection
- Check memory usage
- Return health status
Routes:
- GET /api/images/health - public
- POST /api/images/tag - requires auth, multipart upload
- POST /api/images/tag-base64 - requires auth, JSON body
- GET /api/images/search - requires auth
- GET /api/images/stats - requires auth
Shared Utilities
AppError Classes:
class AppError extends Error {
constructor(message, statusCode = 500, isOperational = true) {
super(message);
this.statusCode = statusCode;
this.isOperational = isOperational;
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message) { super(message, 400); }
}
class AIServiceError extends AppError {
constructor(message) { super(message, 503); }
}
Logger (Winston):
- Daily rotating file transport
- JSON formatting
- Separate error.log and combined.log
- Console output in development
- Methods: info(message, meta), error(message, error), warn(message, meta), debug(message, meta)
ApiKeyGenerator:
- generate(prefix) - create secure 64-char hex key with prefix
- hash(apiKey) - SHA256 hash
- mask(apiKey) - show only first 13 and last 4 chars
- isValidFormat(apiKey) - validate pattern
- extractPrefix(apiKey) - get prefix part
ResponseFormatter:
- success(data, message) - {success: true, message, data, timestamp}
- error(error, message) - {success: false, message, error, timestamp}
Server Setup (src/server.js)
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
// Import middleware and routes
const corsConfig = require('./infrastructure/config/corsConfig');
const container = require('./infrastructure/config/dependencyContainer');
const createImageRoutes = require('./presentation/routes/imageRoutes');
const ImageTaggingController = require('./presentation/controllers/ImageTaggingController');
const ApiKeyAuthMiddleware = require('./presentation/middleware/apiKeyAuth');
const errorHandler = require('./presentation/middleware/errorHandler');
const requestIdMiddleware = require('./presentation/middleware/requestId');
const logger = require('./shared/utils/logger');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(helmet());
app.use(compression());
app.use(cors(corsConfig));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(requestIdMiddleware);
app.use(morgan('combined'));
// Root
app.get('/', (req, res) => {
res.json({
service: 'Property Image Tagging API',
version: '1.0.0',
authentication: 'Simple API Key'
});
});
// Dependency injection
const tagImageUseCase = container.get('tagImageUseCase');
const tagBase64ImageUseCase = container.get('tagBase64ImageUseCase');
const imageRepository = container.get('imageRepository');
const apiKeyRepository = container.get('apiKeyRepository');
const imageController = new ImageTaggingController(
tagImageUseCase,
tagBase64ImageUseCase,
imageRepository,
logger
);
const authMiddleware = new ApiKeyAuthMiddleware(apiKeyRepository, logger);
// Routes
const imageRoutes = createImageRoutes(imageController, authMiddleware);
app.use('/api/images', imageRoutes);
// Error handler (last)
app.use(errorHandler);
// Graceful shutdown
const gracefulShutdown = async (signal) => {
logger.info(`${signal} received, shutting down`);
const pool = container.get('pool');
await pool.end();
process.exit(0);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Start
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
}
module.exports = app;
CLI Tool (scripts/manage-api-keys.js)
Create command-line tool with these commands:
- create [name] [environment] [description] - Generate new API key
- list - Display all keys
- revoke [reason] - Deactivate key
- activate - Reactivate key
Use ApiKeyRepository methods. Show plain text key ONLY on creation.
Critical Requirements
MUST DO:
✅ Use connection pooling for MySQL (max 20) ✅ Use transactions for saving image + tags ✅ Implement retry logic for Claude API (3 retries) ✅ Validate JSON from Claude before parsing ✅ Hash API keys with SHA256 (never store plain text) ✅ Use prepared statements for SQL ✅ Optimize images before sending to Claude (max 2048px) ✅ Calculate image hash for duplicate detection ✅ Return helpful error messages ✅ Use async-retry package for Claude API calls ✅ Use file-type package for magic number validation ✅ Use Sharp for image processing ✅ Use Joi for input validation ✅ Use Winston for logging
MUST NOT DO:
❌ Store plain text API keys ❌ Trust file extensions alone ❌ Skip image hash calculation ❌ Return database errors to client ❌ Skip input validation ❌ Hardcode values (use env vars)
Expected API Response Examples
Successful New Image Tagging:
{
"success": true,
"message": "✅ New image tagged successfully",
"data": {
"imageId": "abc-123-def",
"tags": [
{"category": "View", "value": "Burj Khalifa view", "confidence": 0.98},
{"category": "Furnishing", "value": "fully furnished", "confidence": 0.95}
],
"summary": "Modern luxury apartment with Burj Khalifa view",
"totalTags": 27,
"isDuplicate": false,
"processedAt": "2025-10-31T10:30:00.000Z"
}
}
Duplicate Image Detection:
{
"success": true,
"message": "✅ Duplicate detected - returned cached tags (no cost)",
"data": {
"imageId": "abc-123-def",
"tags": [...],
"isDuplicate": true,
"cachedResult": true,
"costSavings": "This request was FREE - used cached result"
}
}
Error Response:
{
"success": false,
"message": "API key required. Include X-API-Key header.",
"timestamp": "2025-10-31T10:30:00.000Z"
}
Package Dependencies
{
"dependencies": {
"@anthropic-ai/sdk": "^0.32.1",
"async-retry": "^1.3.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"file-type": "^19.5.0",
"helmet": "^8.0.0",
"joi": "^17.13.3",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.11.5",
"sharp": "^0.33.5",
"uuid": "^11.0.3",
"winston": "^3.17.0",
"winston-daily-rotate-file": "^5.0.0"
},
"devDependencies": {
"nodemon": "^3.1.9"
}
}
Environment Variables
Create .env file:
NODE_ENV=development
PORT=3000
ANTHROPIC_API_KEY=your_key_here
DB_HOST=localhost
DB_PORT=3306
DB_NAME=property_tagging
DB_USER=root
DB_PASSWORD=your_password
SKIP_AUTH=true
ALLOWED_ORIGIN=*
LOG_LEVEL=info
Testing Checklist
After implementation:
- Upload JPEG → verify tags returned
- Upload same image twice → verify duplicate detected
- Upload HEIC → verify converted and tagged
- Upload without API key → verify 401
- Upload with invalid key → verify 403
- Upload corrupted file → verify 400
- Search by tag → verify results
- Check database has proper indexes
- Verify Claude API retry works
- Test graceful shutdown
Success Metrics
- API responds within 5 seconds
- Duplicate detection works (same image = cached)
- All image formats supported
- Authentication blocks invalid keys
- Errors handled gracefully
- Database queries optimized
- Logs contain useful info
Start implementation from Domain layer → Application → Infrastructure → Presentation. Follow Clean Architecture strictly. Implement comprehensive error handling at every layer.