Homework #5: Flask API and EC2 Deployment

EE 547: Spring 2026

ImportantAssignment Details

Assigned: 25 March
Due: Tuesday, 8 April at 23:59

Gradescope: Homework 5 | How to Submit

WarningRequirements
  • Python 3.11+ required
  • Docker Desktop installed and running
  • AWS CLI configured with valid credentials
  • HW#4 Problem 2 DynamoDB table (arxiv-papers) populated with data

Overview

Build an authenticated REST API with Flask and deploy it to a live EC2 instance. The API serves paper metadata from DynamoDB, and the deployment makes it accessible over the public internet.

Getting Started

Download the starter code: hw5-starter.zip

unzip hw5-starter.zip
cd hw5-starter

Problem 1: Flask REST API for ArXiv Papers

Implement a REST API that serves ArXiv paper metadata from DynamoDB, with JWT authentication on protected endpoints.

Use only Flask, boto3, PyJWT, gunicorn, and Python standard library modules. Do not use Django, FastAPI, or other web frameworks.

Part A: API Endpoints

Create app.py that implements the following endpoints. All endpoints except /api/stats, /api/login, and the arxiv redirect require a valid JWT in the Authorization header.

Unprotected:

  1. GET /api/stats — Server statistics

    Track request counts in memory (resets on restart).

    {
      "status": "healthy",
      "uptime_seconds": 3842,
      "region": "us-west-2",
      "table": "arxiv-papers",
      "requests": {
        "total": 247,
        "by_status": {"200": 210, "401": 25, "404": 8, "302": 4}
      }
    }
  2. POST /api/login — Authenticate and receive JWT

    Request:

    {"username": "admin", "password": "secret"}

    Success (200):

    {"token": "eyJhbGciOiJIUzI1NiIs...", "expires_in": 3600}

    Invalid credentials (401):

    {"error": "Invalid credentials"}

Protected (require Authorization: Bearer <token>):

  1. GET /api/papers?category={category}&limit={limit} — Recent papers in category

    Default limit: 20.

    {
      "category": "cs.LG",
      "papers": [
        {
          "arxiv_id": "2301.12345",
          "title": "Paper Title",
          "authors": ["Author One", "Author Two"],
          "published": "2023-01-15T10:30:00Z"
        }
      ],
      "count": 5
    }
  2. GET /api/papers/{arxiv_id} — Full paper details by ID

    {
      "arxiv_id": "2301.12345",
      "title": "Paper Title",
      "authors": ["Author One", "Author Two"],
      "abstract": "Full abstract text...",
      "categories": ["cs.LG", "cs.AI"],
      "published": "2023-01-15T10:30:00Z"
    }

    If the paper does not exist, return 404:

    {"error": "Paper not found", "arxiv_id": "2301.99999"}
  3. GET /api/papers/author/{author_name} — Papers by author

    {
      "author": "Author Name",
      "papers": [...],
      "count": 3
    }
  4. GET /api/papers/search?category={category}&start={date}&end={date} — Papers in date range

    Dates are ISO-8601 format (YYYY-MM-DD). Both start and end are required.

    {
      "category": "cs.LG",
      "start": "2023-01-01",
      "end": "2023-12-31",
      "papers": [...],
      "count": 12
    }

    Return 400 if category, start, or end is missing.

  5. GET /api/papers/keyword/{keyword}?limit={limit} — Papers matching keyword

    Default limit: 20.

    {
      "keyword": "transformer",
      "papers": [...],
      "count": 8
    }
  6. GET /api/papers/{arxiv_id}/arxiv — Redirect to paper on arxiv.org

    Returns a 302 Found redirect to https://arxiv.org/abs/{arxiv_id}. No JSON body.

    If the paper does not exist in your table, return 404 (not a redirect).

Error responses:

All errors return JSON with an error field:

  • 400 — Missing or invalid parameters
  • 401 — Missing or invalid token
  • 404 — Resource not found
  • 500 — Server error

Part B: JWT Authentication

Implement token-based authentication using PyJWT.

Users: Define a USERS dict in your application with at least two users:

USERS = {
    "admin": "secret",
    "viewer": "readonly"
}

Plaintext passwords are fine here — the focus is on the token flow, not password storage.

Token creation (/api/login):

import jwt
import time

SECRET_KEY = "your-secret-key"  # Hardcoded is fine for this assignment

def create_token(username):
    payload = {
        "sub": username,
        "iat": int(time.time()),
        "exp": int(time.time()) + 3600,
        "role": "admin"
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

Standard JWT claims (RFC 7519):

  • sub — Subject: identifies who the token represents
  • iat — Issued At: Unix timestamp of token creation
  • exp — Expiration: Unix timestamp after which the token is rejected

JWT payloads also support arbitrary custom claims. The role field above is application-specific metadata embedded directly in the token, available on decode without a database lookup.

Token validation (protected endpoints):

A Python decorator wraps a function with additional behavior — @app.route is one. The same pattern works for authentication: a @require_auth decorator that runs before the route handler, checks the token, and returns 401 if invalid. The route handler only executes if the token is valid.

The decorator should:

  1. Read the Authorization header
  2. Extract the token after Bearer
  3. Decode and validate with PyJWT
  4. Return 401 if the header is missing, malformed, or the token is invalid/expired
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Part C: DynamoDB Integration

The API reads from the arxiv-papers DynamoDB table (the same table and GSIs from HW#4 Problem 2).

Configuration via environment variables:

import os

AWS_REGION = os.environ.get("AWS_REGION", "us-west-2")
DYNAMODB_TABLE = os.environ.get("DYNAMODB_TABLE", "arxiv-papers")

Each endpoint corresponds to a DynamoDB access pattern:

Endpoint DynamoDB Operation
GET /api/papers?category= Main table query, sort key descending
GET /api/papers/{arxiv_id} GSI (PaperIdIndex)
GET /api/papers/author/{name} GSI (AuthorIndex)
GET /api/papers/search?... Main table, sort key range
GET /api/papers/keyword/{kw} GSI (KeywordIndex)

Part D: Dockerfile

A Dockerfile and requirements.txt are provided in the starter code.

Code: Dockerfile
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

ENV PYTHONUNBUFFERED=1
EXPOSE 8080

CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
Code: requirements.txt
flask>=3.0
boto3>=1.28.0
pyjwt>=2.8.0
gunicorn>=21.2.0

The Dockerfile uses gunicorn as the production WSGI server (not the Flask development server). For local development, run directly with Python:

pip install -r requirements.txt
python app.py

Your app.py should include a development entry point:

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=True)

Part E: Local Testing

Build and run with Docker:

docker build -t arxiv-api:latest .
docker run --rm -p 8080:8080 \
    -e AWS_ACCESS_KEY_ID \
    -e AWS_SECRET_ACCESS_KEY \
    -e AWS_SESSION_TOKEN \
    arxiv-api:latest

The -e flags pass your local AWS credentials to the container. If you use named profiles, export the credentials first.

Test your endpoints:

# Stats (no auth required)
curl http://localhost:8080/api/stats

# Login — copy the token from the response
curl -s -X POST http://localhost:8080/api/login \
    -H "Content-Type: application/json" \
    -d '{"username": "admin", "password": "secret"}'

# Protected endpoints (replace <token> with the value from login)
curl -H "Authorization: Bearer <token>" \
    "http://localhost:8080/api/papers?category=cs.LG&limit=5"

curl -H "Authorization: Bearer <token>" \
    http://localhost:8080/api/papers/author/LeCun

# Should return 401
curl http://localhost:8080/api/papers?category=cs.LG

# Should also return 401
curl -H "Authorization: Bearer invalid-token" \
    http://localhost:8080/api/papers?category=cs.LG

Deliverables

See Submission.

ImportantGrading Commands

We will validate your submission by running the following commands from your q1/ directory:

docker build -t arxiv-api:latest .
docker run --rm -p 8080:8080 \
    -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY -e AWS_SESSION_TOKEN \
    -e DYNAMODB_TABLE=arxiv-papers \
    arxiv-api:latest

We will then verify:

  • Stats endpoint responds without authentication and includes uptime and request counts
  • Login returns a valid JWT for correct credentials and 401 for incorrect
  • All five query endpoints return correct JSON structure
  • Arxiv redirect returns 302 with correct Location header
  • Protected endpoints return 401 without a token
  • Protected endpoints return 401 with an expired or invalid token
  • Error responses use correct HTTP status codes (400, 401, 404)
  • All endpoints respond within 2 seconds

Problem 2: Deploy to EC2

Deploy your Problem 1 API to an EC2 instance accessible over the public internet.

Part A: EC2 Instance

Launch an Ubuntu EC2 instance with the following configuration:

  • AMI: Ubuntu Server 24.04 LTS
  • Instance type: t2.micro (free tier eligible)
  • Key pair: Use an existing key pair or create a new one
  • Network: Default VPC, public subnet, auto-assign public IP enabled
  • Security group: Allow inbound on port 22 (SSH) and port 8080 (your API)

The instance must have a public IP to be reachable from outside AWS. Instances in private subnets or without a public IP are only accessible from within the VPC.

If you still have a key pair and security group from the demo, you can reuse them. Add a rule for port 8080 if it’s not already open:

Type Port Source Description
Custom TCP 8080 0.0.0.0/0 Flask API

Part B: IAM Role

Create an IAM role that grants the instance read access to your DynamoDB table.

1. Create a policy with the following permissions:

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:Query",
                "dynamodb:GetItem",
                "dynamodb:BatchGetItem",
                "dynamodb:Scan",
                "dynamodb:DescribeTable"
            ],
            "Resource": "arn:aws:dynamodb:*:*:table/arxiv-papers*"
        }
    ]
}

The wildcard after arxiv-papers covers the table and its GSIs. DescribeTable allows boto3 to check the table exists at startup.

2. Create a role with trusted entity type “AWS service” → “EC2”, and attach the policy.

3. Attach the role to your running instance: Actions → Security → Modify IAM role.

Verify on the instance:

aws sts get-caller-identity

This should return an ARN containing your role name. sts:GetCallerIdentity requires no IAM permissions — it always works when a role is attached. If it fails with a credentials error, the role is not attached or hasn’t propagated yet (wait a minute and retry).

Part C: Deploy

Copy your Q1 application to the instance and run it.

Copy files from your local machine:

scp -i <key-file> -r q1/ ubuntu@<public-ip>:~/arxiv-api

SSH into the instance:

ssh -i <key-file> ubuntu@<public-ip>
cd arxiv-api

Install dependencies and run with gunicorn:

pip install -r requirements.txt
gunicorn --bind 0.0.0.0:8080 --workers 2 --daemon app:app

The --daemon flag runs gunicorn in the background. It persists after you disconnect.

On EC2 with an attached IAM role, boto3 automatically finds credentials through the instance metadata service. No environment variables or aws configure needed.

Part C2: Docker Deployment

Running gunicorn directly works, but the application depends on the correct Python version and packages being installed on the instance. Docker packages the application with its dependencies into a single image — the same image that runs locally runs identically on EC2.

Stop the running gunicorn process:

pkill gunicorn

Build and run the container:

docker build -t arxiv-api:latest .
docker run -d -p 8080:8080 arxiv-api:latest

The -d flag runs the container in the background. Containers on EC2 with an attached IAM role automatically have access to the instance credentials through the metadata service — no -e AWS_ACCESS_KEY_ID flags needed.

Docker must be installed on the instance. If you launched with the user-data script from the demo, it is already present. Otherwise install Docker manually (see Demo 03a § Instance Setup).

Your final deployment should use Docker.

Part D: Verification

From your local machine, verify the deployment:

# Stats
curl http://<public-ip>:8080/api/stats

# Login
curl -X POST http://<public-ip>:8080/api/login \
    -H "Content-Type: application/json" \
    -d '{"username": "admin", "password": "secret"}'

# Query (use the token from login)
curl -H "Authorization: Bearer <token>" \
    "http://<public-ip>:8080/api/papers?category=cs.LG&limit=3"

All three requests must succeed. If stats responds but queries fail with 500 errors, the IAM role likely lacks DynamoDB permissions.

Part E: README

Create q2/README.md documenting:

  1. Your EC2 instance’s public IP address
  2. The IAM role ARN attached to the instance
  3. Whether you deployed with Docker or direct gunicorn
  4. Any issues encountered during deployment and how you resolved them

Deliverables

See Submission.

ImportantGrading

We will test your deployed API from outside AWS:

curl http://<your-ip>:8080/api/stats
curl -X POST http://<your-ip>:8080/api/login \
    -H "Content-Type: application/json" \
    -d '{"username": "admin", "password": "secret"}'
curl -H "Authorization: Bearer <token>" \
    "http://<your-ip>:8080/api/papers?category=cs.LG&limit=5"

We will verify:

  • Instance is reachable on port 8080
  • Stats, login, and query endpoints respond correctly
  • DynamoDB queries return data (not empty results)
  • Server remains running (not a foreground process tied to your SSH session)

Keep your instance running until grades are posted. t2.micro is free tier eligible (750 hours/month). Terminate after grading to avoid charges if your free tier has expired.


TipSubmission
README.md
q1/
├── app.py
├── Dockerfile
└── requirements.txt
q2/
└── README.md