DEV Community

Cover image for Cloud Resume Challenge - Chunk 2 - Building the API
Trinity Klein
Trinity Klein

Posted on

Cloud Resume Challenge - Chunk 2 - Building the API

After getting my front-end live on S3 + CloudFront in Chunk 1, it was time to give it some brains. ๐Ÿง 

The goal for this stage:
๐Ÿ‘‰ Add a visitor counter (hit counter โ†’ visitor counter) to my portfolio website.

This wasnโ€™t just about displaying a number, it was about learning how to stitch together AWS Lambda, API Gateway, DynamoDB, and IAM into a working serverless backend.


๐Ÿ—„๏ธ Designing the Visitor Counter

The stack I chose:

  • DynamoDB โ†’ store visitor data (IPs + visit counts).
  • Lambda โ†’ serverless compute that updates/query the table.
  • API Gateway โ†’ REST API to expose the Lambda function securely.
  • IAM Roles โ†’ restrict who/what can read/write from DynamoDB.

Hereโ€™s the flow:

Browser โ†’ API Gateway โ†’ Lambda โ†’ DynamoDB
Enter fullscreen mode Exit fullscreen mode

On page load, the API Gateway calls the Lambda function, which fetches & updates the DynamoDB table. The number is then displayed in my siteโ€™s footer.

If the data canโ€™t be fetched, the site gracefully falls back to showing "Loading...".


๐Ÿ” From Hit Counter โ†’ Visitor Counter

Originally, this was just a simple hit counter, every page refresh added +1. But I refactored it into a proper visitor counter with:

  • Total visits (every page load).
  • Unique visitors (per IP, per 24-hour window).

This required:

  • A new DynamoDB table to store hashed IP addresses.
  • A smarter Lambda function (see below).
  • Test data (dummy items) to validate in production.

๐Ÿ“ The Lambda Function (v2)

Hereโ€™s the core Lambda function I deployed (Python 3.9):

# Version 2 of lambda function
# Stores IP addresses in a one-way hash
# Only counts unique visits once per 24 hours

import json
import boto3
import os
from datetime import datetime, timedelta
import hashlib

dynamodb = boto3.client('dynamodb')
TABLE_NAME = os.environ.get('TABLE_NAME', 'VisitorCounter')
UNIQUE_VISITOR_WINDOW_HOURS = 24  # uniqueness window

def handler(event, context):
    print("Incoming event:", json.dumps(event, indent=2))

    # Extract and hash IP
    ip_address = get_ip_address(event)
    if ip_address == "0.0.0.0":
        return error_response("Unable to determine IP")

    hashed_ip = hash_ip(ip_address)
    now = datetime.utcnow()
    now_str = now.isoformat()

    try:
        response = dynamodb.get_item(
            TableName=TABLE_NAME,
            Key={'ip_address': {'S': hashed_ip}}
        )

        is_new_visitor = False

        if 'Item' in response:
            last_visit = response['Item'].get('last_visit', {}).get('S')
            last_visit_time = datetime.fromisoformat(last_visit)

            if now - last_visit_time >= timedelta(hours=UNIQUE_VISITOR_WINDOW_HOURS):
                is_new_visitor = True
                dynamodb.update_item(
                    TableName=TABLE_NAME,
                    Key={'ip_address': {'S': hashed_ip}},
                    UpdateExpression="SET visit_count = visit_count + :inc, last_visit = :now",
                    ExpressionAttributeValues={
                        ":inc": {"N": "1"},
                        ":now": {"S": now_str}
                    }
                )
        else:
            is_new_visitor = True
            dynamodb.put_item(
                TableName=TABLE_NAME,
                Item={
                    "ip_address": {"S": hashed_ip},
                    "visit_count": {"N": "1"},
                    "first_visit": {"S": now_str},
                    "last_visit": {"S": now_str}
                }
            )

        scan = dynamodb.scan(
            TableName=TABLE_NAME,
            AttributesToGet=["visit_count"]
        )
        total_visits = sum(int(item["visit_count"]["N"]) for item in scan["Items"])
        unique_visitors = len(scan["Items"])

        return {
            "statusCode": 200,
            "headers": {
                "Access-Control-Allow-Origin": "*",
                "Content-Type": "application/json"
            },
            "body": json.dumps({
                "unique_visitors": unique_visitors,
                "total_visits": total_visits,
                "is_new_visitor": is_new_visitor
            })
        }

    except Exception as e:
        print("Error:", str(e))
        return error_response("Internal server error")

def get_ip_address(event):
    headers = event.get("headers", {})
    ip_sources = [
        event.get("requestContext", {}).get("http", {}).get("sourceIp"),
        event.get("requestContext", {}).get("identity", {}).get("sourceIp"),
        headers.get("x-forwarded-for", "").split(",")[0].strip(),
        headers.get("X-Forwarded-For", "").split(",")[0].strip(),
        headers.get("x-real-ip"),
        headers.get("X-Real-IP"),
        headers.get("cf-connecting-ip"),
        headers.get("CF-Connecting-IP"),
    ]
    for ip in ip_sources:
        if ip:
            return ip
    return "0.0.0.0"

def hash_ip(ip):
    return hashlib.sha256(ip.encode()).hexdigest()

def error_response(message):
    return {
        "statusCode": 500,
        "body": json.dumps({"error": message})
    }
Enter fullscreen mode Exit fullscreen mode

Key features:

  • ๐Ÿ”’ Privacy-first โ†’ IP addresses are SHA-256 hashed.
  • ๐Ÿ•’ Uniqueness window โ†’ Only 1 count per IP in 24 hours.
  • ๐Ÿ“Š Metrics returned โ†’ total visits, unique visitors, is_new_visitor.

๐Ÿ›ก๏ธ IAM Role Setup

To keep things secure:

  • The Lambda function only has dynamodb:GetItem, PutItem, UpdateItem, and Scan permissions for the specific table.
  • API Gateway was given permission to invoke the Lambda.
  • No overly broad permissions, least privilege only.
# IAM Policy Example
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:UpdateItem", "dynamodb:GetItem"],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/VisitorCounter"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ“ฆ S3 Bucket Improvements

While working on the API, I also hardened my S3 setup:

  • โœ… Versioning enabled โ†’ recover files in case of accidental overwrite.
  • โœ… Lifecycle policies โ†’ move old versions to cheaper storage.

This gave me resilience + cost control.


๐Ÿ”„ REST API vs Real-Time

I briefly considered building a real-time visitor counter using:

  • API Gateway (WebSockets)
  • DynamoDB Streams

But hereโ€™s the truth:

  • โš ๏ธ It would be more expensive at scale.
  • โš ๏ธ It would be much more complex to set up and optimize.
  • โœ… Andโ€ฆ the user experience would be basically the same.

So I stuck with a REST API, simple, reliable, and cost-effective.


๐Ÿš€ Lessons Learned in Chunk 2

By the end of this chunk, I achieved:
โœ… Built an API Gateway + Lambda + DynamoDB visitor counter
โœ… Upgraded from hit counter โ†’ unique visitor counter
โœ… Secured roles with IAM least privilege
โœ… Added resilience with bucket versioning + lifecycle policies
โœ… Made intentional design decisions (REST > WebSocket for this use case)

This was the first time my project felt full-stack serverless, front-end + backend working together. ๐Ÿ’ก


๐ŸŒŸ Whatโ€™s Next?

Next up: Chunk 3, adding a CI/CD pipeline so that deployments are automated, tested, and production-ready.

Thatโ€™s when the project will really level up. โšก

Drop a comment with how you approached your visitor counter, did you try REST, WebSockets, or something else? I would love to know how you approached it.


๐Ÿ“š Helpful Resources

Hereโ€™s what helped me in Chunk 2:


๐Ÿซฐ๐Ÿป Letโ€™s Connect

If youโ€™re following this challenge, or just passing by, Iโ€™d love to connect!

Iโ€™m always happy to help if you need guidance, want to swap ideas, or just chat about tech. ๐Ÿš€

Iโ€™m also open to new opportunities, so if you have any inquiries or collaborations in mind, let me know!

Top comments (0)