A serverless API built on AWS that creates photo grids from uploaded images using Lambda, API Gateway, DynamoDB, and S3.
User Upload β API Gateway β Lambda (add_image) β S3 (source-bucket)
β
DynamoDB (GridBuilder)
β
User Request β API Gateway β Lambda (generate_grid) β S3 (destination-bucket)
- AWS Account
- AWS CLI configured
- Python 3.12/3.13
- Basic knowledge of AWS services
api-backend-manual/
βββ add_image/
β βββ app.py
β βββ app_solution.py
βββ generate_grid/
β βββ app.py
β βββ app_solution.py
βββ source/
βββ image01.jpg - image16.jpg
βββ (deployment files)
Create a DynamoDB table to store the mapping between grid IDs and S3 image keys.
aws dynamodb create-table \
--table-name GridBuilder \
--attribute-definitions \
AttributeName=uniqueGridId,AttributeType=S \
AttributeName=s3Key,AttributeType=S \
--key-schema \
AttributeName=uniqueGridId,KeyType=HASH \
AttributeName=s3Key,KeyType=RANGE \
--provisioned-throughput \
ReadCapacityUnits=5,WriteCapacityUnits=5Expected Output:
{
"TableDescription": {
"TableName": "GridBuilder",
"TableStatus": "CREATING",
"KeySchema": [
{
"AttributeName": "uniqueGridId",
"KeyType": "HASH"
},
{
"AttributeName": "s3Key",
"KeyType": "RANGE"
}
]
}
}This function receives image uploads and stores them in S3.
cd ~/environment/api-backend-manual/add_image
zip -r ../add_image.zip app.py
aws lambda create-function \
--function-name add_image \
--runtime python3.12 \
--timeout 30 \
--handler app.lambda_handler \
--role $LAMBDA_ROLE \
--environment Variables={SOURCE_BUCKET=$SOURCE_BUCKET} \
--zip-file fileb://~/environment/api-backend-manual/add_image.zipThis function generates the image grid from uploaded images.
cd ~/environment/api-backend-manual/generate_grid
# Install Pillow dependency
pip3 install Pillow -t ~/environment/api-backend-manual/source
# Create deployment package
cd ~/environment/api-backend-manual/source
cp ../generate_grid/app.py .
zip -r lambda.zip app.py PIL* pillow*
aws lambda create-function \
--function-name generate_grid \
--runtime python3.13 \
--timeout 20 \
--memory-size 256 \
--handler app.lambda_handler \
--role $LAMBDA_ROLE \
--environment Variables={SOURCE_BUCKET=$SOURCE_BUCKET,DESTINATION_BUCKET=$DESTINATION_BUCKET} \
--zip-file fileb://lambda.zipConfiguration in app.py:
tile_size = 700 # Adjust this value for larger/smaller grid tiles- Navigate to API Gateway in AWS Console
- Click Create API β HTTP API β Build
- Configure:
- API name:
generate-grid - Add integrations:
- Lambda:
add_imagefunction - Lambda:
generate_gridfunction
- Lambda:
- API name:
Add the following routes:
| Method | Path | Integration |
|---|---|---|
| POST | /add_image | add_image Lambda |
| POST | /generate_grid | generate_grid Lambda |
- Navigate to Stages β $default
- Enable Automatic Deployment
- Deploy the API
Copy the invoke URL (format: https://xxxxx.execute-api.region.amazonaws.com)
export baseUrl="https://YOUR-API-ID.execute-api.us-west-2.amazonaws.com"
export uniqueGridId=$(date +%s)
echo "Grid ID: ${uniqueGridId}"cd ~/environment/api-backend-manual/source
for i in {01..16}; do
curl -X POST --data-binary @image${i}.jpg \
"${baseUrl}/add_image?uniqueGridId=${uniqueGridId}"
donecurl -s -X POST "${baseUrl}/generate_grid?uniqueGridId=${uniqueGridId}" | jq '.'Expected Response:
{
"message": "built grid",
"presigned_url": "https://destination-images-bucket.s3.amazonaws.com/xxx.jpg?..."
}Extract and open the presigned URL:
curl -s -X POST "${baseUrl}/generate_grid?uniqueGridId=${uniqueGridId}" | jq -r '.presigned_url'Copy the URL and open in a browser to view your grid.
Symptom:
{
"message": "Not Found"
}Diagnosis:
# Check if routes exist
aws apigatewayv2 get-routes --api-id YOUR-API-ID --region us-west-2
# Look for missing "Target" field in routesSolution:
The route exists but is not connected to Lambda integration.
- Create Lambda integration:
INTEGRATION_ID=$(aws apigatewayv2 create-integration \
--api-id YOUR-API-ID \
--integration-type AWS_PROXY \
--integration-uri arn:aws:lambda:REGION:ACCOUNT:function:generate_grid \
--payload-format-version 2.0 \
--region us-west-2 \
--query 'IntegrationId' \
--output text)
echo "Integration ID: $INTEGRATION_ID"- Attach integration to route:
aws apigatewayv2 update-route \
--api-id YOUR-API-ID \
--route-id YOUR-ROUTE-ID \
--target "integrations/$INTEGRATION_ID" \
--region us-west-2- Grant API Gateway permission to invoke Lambda:
aws lambda add-permission \
--function-name generate_grid \
--statement-id apigateway-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:REGION:ACCOUNT:API-ID/*/*" \
--region us-west-2Symptom:
ModuleNotFoundError: No module named 'PIL'
Diagnosis:
# Check if PIL is in the Lambda deployment package
unzip -l lambda.zip | grep -i PILSolution:
Pillow library is missing from the Lambda deployment package.
cd ~/environment/api-backend-manual/source
# Install Pillow locally
pip3 install Pillow -t .
# Add to existing zip
zip -ur lambda.zip PIL pillow-*.dist-info pillow.libs
# Update Lambda
aws lambda update-function-code \
--function-name generate_grid \
--zip-file fileb://lambda.zip \
--region us-west-2Symptom:
Runtime.ImportModuleError: Unable to import module 'app': No module named 'app'
Diagnosis:
# Check if app.py is in the zip root
unzip -l lambda.zip | grep "app.py"Solution:
The app.py file is missing or in the wrong location in the zip file.
cd ~/environment/api-backend-manual/source
# Find the correct app.py
find ~/environment/api-backend-manual -name "app.py" -type f
# Copy generate_grid app.py to source directory
cp ../generate_grid/app.py .
# Add to zip (must be in root, not subdirectory)
zip -u lambda.zip app.py
# Update Lambda
aws lambda update-function-code \
--function-name generate_grid \
--zip-file fileb://lambda.zip \
--region us-west-2Symptom: Generated grid images are too small or low quality.
Solution:
Edit the tile_size variable in app.py:
cd ~/environment/api-backend-manual/source
nano app.pyFind and modify:
# Change from:
tile_size = 100
# To (for larger images):
tile_size = 700Redeploy:
zip -u lambda.zip app.py
aws lambda update-function-code \
--function-name generate_grid \
--zip-file fileb://lambda.zip \
--region us-west-2Symptom:
{
"message": "Internal Server Error"
}Diagnosis:
Check Lambda logs for detailed error messages:
# View live logs
aws logs tail /aws/lambda/generate_grid --region us-west-2 --follow
# View recent logs (last 10 minutes)
aws logs tail /aws/lambda/generate_grid --region us-west-2 --since 10mCommon Causes:
- Missing dependencies in deployment package
- Incorrect environment variables
- Lambda timeout (increase to 30s)
- Insufficient Lambda memory (increase to 512MB)
# List all routes
aws apigatewayv2 get-routes --api-id YOUR-API-ID --region us-west-2
# List integrations
aws apigatewayv2 get-integrations --api-id YOUR-API-ID --region us-west-2
# Get stage info
aws apigatewayv2 get-stages --api-id YOUR-API-ID --region us-west-2# Get function details
aws lambda get-function --function-name generate_grid --region us-west-2
# View environment variables
aws lambda get-function-configuration \
--function-name generate_grid \
--region us-west-2 \
--query 'Environment'# Verify table exists
aws dynamodb describe-table --table-name GridBuilder
# Scan table contents
aws dynamodb scan --table-name GridBuilder --region us-west-2add_image function:
SOURCE_BUCKET: S3 bucket for uploaded images
generate_grid function:
SOURCE_BUCKET: S3 bucket with source imagesDESTINATION_BUCKET: S3 bucket for generated grids
| Setting | add_image | generate_grid |
|---|---|---|
| Runtime | Python 3.12 | Python 3.13 |
| Timeout | 30s | 20s |
| Memory | 128 MB | 256 MB |
| Handler | app.lambda_handler | app.lambda_handler |
To avoid ongoing charges, delete the resources:
# Delete Lambda functions
aws lambda delete-function --function-name add_image --region us-west-2
aws lambda delete-function --function-name generate_grid --region us-west-2
# Delete API Gateway
aws apigatewayv2 delete-api --api-id YOUR-API-ID --region us-west-2
# Delete DynamoDB table
aws dynamodb delete-table --table-name GridBuilder --region us-west-2
# Empty and delete S3 buckets (do this via Console or with proper scripts)import os
import json
import boto3
import base64
s3 = boto3.client("s3")
dynamodb = boto3.client("dynamodb")
table_name = "GridBuilder"
source_bucket = os.getenv('SOURCE_BUCKET')
def lambda_handler(event, context):
event_body = base64.b64decode(
event["body"]) if event["isBase64Encoded"] else event["body"]
uniqueGridId = event["queryStringParameters"]["uniqueGridId"]
# Save the s3 object with a random name
object_key = os.urandom(16).hex() + ".jpg"
print(f"Saving to bucket: {source_bucket} key: {object_key}")
s3.put_object(
Bucket=source_bucket,
Key=object_key,
Body=event_body,
ContentType='image/jpg'
)
# Save the mapping from uniqueGridId to s3 object
dynamodb.put_item(
TableName=table_name,
Item={
"uniqueGridId": {"S": uniqueGridId},
"s3Key": {"S": object_key}
}
)
return {
"statusCode": 200,
"headers": {"access-control-allow-origin": "*"},
"body": json.dumps({
"message": "image saved",
"image_size": len(event_body)
}),
}import math
import os
import json
import boto3
import io
import tempfile
from PIL import Image
source_bucket = os.getenv('SOURCE_BUCKET')
destination_bucket = os.getenv('DESTINATION_BUCKET')
s3 = boto3.client("s3")
dynamodb = boto3.client("dynamodb")
table_name = "GridBuilder"
tile_size = 700 # Adjust this for larger/smaller tiles
def lambda_handler(event, context):
uniqueGridId = event["queryStringParameters"]["uniqueGridId"]
# Ask DynamoDB for the list of images
response = dynamodb.query(
TableName=table_name,
KeyConditions={
"uniqueGridId": {
"AttributeValueList": [{"S": uniqueGridId}],
'ComparisonOperator': 'EQ'
}
}
)
source_images = [item["s3Key"]["S"] for item in response["Items"]]
image_count = len(source_images)
print(f"Converting: {image_count} source images.")
# Calculate the height, width of the grid
tiles_width = math.floor(math.sqrt(image_count))
tiles_height = math.ceil(image_count / tiles_width)
print(f"Creating: {tiles_width} x {tiles_height} grid.")
destination_image = Image.new(
mode="RGB",
size=(tiles_width * tile_size, tiles_height * tile_size)
)
for y in range(tiles_height):
for x in range(tiles_width):
if source_images:
filename = source_images.pop()
response = s3.get_object(Bucket=source_bucket, Key=filename)
image_data = response['Body'].read()
img = Image.open(io.BytesIO(image_data))
img_width = img.size[0]
img_height = img.size[1]
# Crop the image to a square
crop_square = min(img.size)
img = img.crop((
(img_width - crop_square) // 2,
(img_height - crop_square) // 2,
(img_width + crop_square) // 2,
(img_height + crop_square) // 2
))
img = img.resize((tile_size, tile_size))
# Draw the image onto the destination grid
destination_image.paste(img, (x * tile_size, y * tile_size))
# Save the output image to a temp file
temp_file = tempfile.NamedTemporaryFile(suffix='.jpg').name
destination_image.save(temp_file)
print(f"Creating temp file {temp_file}")
# Copy the grid to a randomly named object in destination bucket
destination_key = os.urandom(16).hex() + ".jpg"
with open(temp_file, 'rb') as data:
s3.put_object(
Bucket=destination_bucket,
Key=destination_key,
Body=data,
ContentType='image/jpg'
)
print(f"Saved file to s3 bucket: {destination_bucket}, key: {destination_key}")
presigned_url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": destination_bucket, "Key": destination_key},
ExpiresIn=5 * 60
)
return {
"statusCode": 200,
"headers": {"access-control-allow-origin": "*"},
"body": json.dumps({
"message": "built grid",
"presigned_url": presigned_url
}),
}This project is for educational purposes.
Contributions welcome! Please open an issue or submit a pull request.
- AWS Lambda Documentation
- API Gateway Documentation
- DynamoDB Documentation
- Boto3 Documentation
- Pillow Documentation
Built with β€οΈ using AWS Serverless
