Gallery Service provides photo discovery functionality through trending tags and tag-based search. It consumes data from AI Service’s label detection and presents it through user-friendly APIs.
Main Functions:
Position in System:

The Gallery Service consists of:
User Uploads Image
↓
Article Service → S3 Bucket
↓
S3 Event → AI Service
↓
Label Detection
↓
├─→ Save to GalleryPhotosTable
└─→ Update GalleryTrendsTable (increment count)
↓
Gallery Service (Read-only)
↓
Frontend Display
Gallery Service imports from Core Stack:
# From core-infra stack
Imports:
- GalleryPhotosTableName
- GalleryTrendsTableName
Deployment Order:
Purpose: Return list of trending tags sorted by popularity
Configuration:
get_trending_tags.lambda_handler
Logic Flow:
1. Parse query parameters (limit)
2. Scan all tags from GalleryTrendsTable
3. Sort by count (descending) in-memory
4. Return top N tags
Code Structure:
def lambda_handler(event, context):
# Parse parameters
limit = int(params.get("limit", 20))
# Scan all tags
all_tags = []
while True:
response = table.scan(**scan_kwargs)
all_tags.extend(response.get('Items', []))
if 'LastEvaluatedKey' not in response:
break
# Sort by count
all_tags.sort(key=lambda x: x.get('count', 0), reverse=True)
# Return top N
return {
'statusCode': 200,
'body': json.dumps({
'items': all_tags[:limit],
'total_tags': len(all_tags)
})
}
Response Example:
{
"items": [
{
"tag_name": "Beach",
"count": 150,
"cover_image": "articles/abc123/image.jpg",
"last_updated": "2024-01-15T10:30:00Z"
},
{
"tag_name": "Mountain",
"count": 120,
"cover_image": "articles/def456/image.jpg",
"last_updated": "2024-01-14T15:20:00Z"
}
],
"total_tags": 500
}
Purpose: Search photos with specific tag
Configuration:
get_articles_by_tag.lambda_handler
Logic Flow:
1. Parse and validate tag parameter
2. Scan GalleryPhotosTable
3. Filter photos with matching tag (in-memory)
4. Limit results
5. Return photos
Code Structure:
def lambda_handler(event, context):
# Validate parameters
tag = params.get("tag", "").strip().lower()
limit = int(params.get("limit", 50))
if not tag:
return {
'statusCode': 400,
'body': json.dumps({'error': 'tag parameter is required'})
}
# Scan and filter
matching_photos = []
while len(matching_photos) < limit:
response = photos_table.scan(**scan_kwargs)
for item in response.get('Items', []):
photo_tags = [t.lower() for t in item.get('tags', [])]
if tag in photo_tags:
matching_photos.append(item)
if 'LastEvaluatedKey' not in response:
break
return {
'statusCode': 200,
'body': json.dumps({'items': matching_photos[:limit]})
}
Response Example:
{
"items": [
{
"photo_id": "article-123-image-1",
"image_url": "articles/abc123/image.jpg",
"tags": ["beach", "sunset", "ocean"],
"status": "public",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
Purpose: Store photo metadata and tags for gallery display
Schema:
| Field | Type | Description |
|---|---|---|
| photo_id | String (PK) | Unique identifier (e.g., “article-123-image-1”) |
| image_url | String | S3 key (e.g., “articles/abc123/image.jpg”) |
| tags | String[] | Combined user tags + auto-detected tags |
| autoTags | String[] | Deprecated (backward compatibility) |
| status | String | “public” or “private” |
| created_at | String | ISO 8601 timestamp |
| createdAt | String | Alias for frontend |
Indexes:
photo_idData Population:
save_to_gallery Lambda
Purpose: Track trending tags and popularity statistics
Schema:
| Field | Type | Description |
|---|---|---|
| tag_name | String (PK) | Tag name (e.g., “beach”) |
| count | Number | Number of photos with this tag |
| cover_image | String | S3 key for cover image (optional) |
| last_updated | String | ISO 8601 timestamp |
Indexes:
tag_nameData Population:

Purpose: Get list of trending tags
Request:
GET /gallery/trending?limit=20 HTTP/1.1
Host: {api-gateway-url}
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| limit | integer | No | 20 | Maximum number of tags to return |
Success Response (200):
{
"items": [
{
"tag_name": "Beach",
"count": 150,
"cover_image": "articles/abc123/image.jpg",
"last_updated": "2024-01-15T10:30:00Z"
}
],
"total_tags": 500
}
Error Response (500):
{
"error": "Internal error: Gallery Trends table not configured"
}
Frontend Integration:
async function getTrendingTags(limit: number = 20) {
const response = await fetch(
`${API_BASE_URL}/gallery/trending?limit=${limit}`
);
if (!response.ok) {
throw new Error('Failed to fetch trending tags');
}
return await response.json();
}
// Usage
const data = await getTrendingTags(10);
console.log(data.items);
Purpose: Search photos by tag
Request:
GET /gallery/articles?tag=beach&limit=50 HTTP/1.1
Host: {api-gateway-url}
Query Parameters:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| tag | string | Yes | - | Tag name to search (case-insensitive) |
| limit | integer | No | 50 | Maximum number of photos to return |
Success Response (200):
{
"items": [
{
"photo_id": "article-123-image-1",
"image_url": "articles/abc123/image.jpg",
"tags": ["beach", "sunset", "ocean"],
"status": "public",
"created_at": "2024-01-15T10:30:00Z"
}
]
}
Error Responses:
// 400 - Bad Request
{
"error": "tag parameter is required"
}
// 500 - Internal Server Error
{
"error": "Internal error: Gallery Photos table not configured"
}
Frontend Integration:
async function getPhotosByTag(tag: string, limit: number = 50) {
const response = await fetch(
`${API_BASE_URL}/gallery/articles?tag=${encodeURIComponent(tag)}&limit=${limit}`
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch photos');
}
return await response.json();
}
// Usage
const photos = await getPhotosByTag('beach', 20);
console.log(photos.items);
Trending Tags View:

Search Results View:

Lambda Configuration:
| Function | Timeout | Memory | Avg Execution | Cold Start |
|---|---|---|---|---|
| GetTrendingTags | 30s | 512 MB | ~2-5s | ~1-2s |
| GetArticlesByTag | 30s | 512 MB | ~3-8s | ~1-2s |
DynamoDB Operations:
Issue: Scan entire table on every request
Impact:
Solution: Add Global Secondary Indexes (GSI)
Issue: Load all tags into memory, sort, then return top N
Impact:
Solution: Use DynamoDB GSI with count as sort key
# Recommended GSI
GalleryTrendsTable:
GlobalSecondaryIndexes:
- IndexName: CountIndex
KeySchema:
- AttributeName: dummy_pk # All items have same value
KeyType: HASH
- AttributeName: count
KeyType: RANGE
Projection:
ProjectionType: ALL
Issue: Scan all photos, filter by tag in Lambda
Impact:
Solution: Use DynamoDB GSI or ElasticSearch
# Recommended GSI
GalleryPhotosTable:
GlobalSecondaryIndexes:
- IndexName: TagIndex
KeySchema:
- AttributeName: tag
KeyType: HASH
- AttributeName: created_at
KeyType: RANGE
Projection:
ProjectionType: ALL
Note: DynamoDB doesn’t support array attributes in GSI. Need to denormalize data or use ElasticSearch.
Issue: Every request queries DynamoDB
Impact:
Solution: Add caching layer
Options:
Example with ElastiCache:
import redis
redis_client = redis.Redis(host=REDIS_HOST, port=6379)
CACHE_TTL = 300 # 5 minutes
def get_trending_tags_cached(limit):
cache_key = f"trending_tags:{limit}"
# Try cache first
cached = redis_client.get(cache_key)
if cached:
return json.loads(cached)
# Query DynamoDB
result = get_trending_tags_from_db(limit)
# Save to cache
redis_client.setex(cache_key, CACHE_TTL, json.dumps(result))
return result
Issue: Return all results (up to limit)
Impact:
Solution: Implement cursor-based pagination
def lambda_handler(event, context):
params = event.get('queryStringParameters', {})
limit = int(params.get('limit', 20))
cursor = params.get('cursor') # LastEvaluatedKey from previous request
scan_kwargs = {'Limit': limit}
if cursor:
scan_kwargs['ExclusiveStartKey'] = json.loads(base64.b64decode(cursor))
response = table.scan(**scan_kwargs)
result = {
'items': response['Items'],
'cursor': None
}
if 'LastEvaluatedKey' in response:
result['cursor'] = base64.b64encode(
json.dumps(response['LastEvaluatedKey']).encode()
).decode()
return {'statusCode': 200, 'body': json.dumps(result)}
Add Caching
Optimize Lambda Memory
Add Pagination
Error Handling
Add DynamoDB GSIs
Implement ElasticSearch
Add ElastiCache
Implement Event-Driven Updates
Assumptions:
DynamoDB:
Lambda:
API Gateway:
Total: ~$0.60/day or ~$18/month
With CloudFront + ElastiCache:
Total: ~$1.58/day or ~$47/month
Note: Higher upfront cost but better performance and scalability.