Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 

Repository files navigation

AWS API Gateway Full Project

🔨Technologies :

AWS Terraform Lambda DynamoDB

A production‑ready, minimal‑cost serverless CRUD API using Amazon API Gateway (HTTP API v2), AWS Lambda (Python), and Amazon DynamoDB. Includes CORS, logging, throttling, infra-as-code with Terraform, and step-by-step commands.

✅ Default: No auth (public API)

🔐 Optional upgrades section shows how to add Cognito JWT auth, custom domain, and CI/CD.


1) Architecture

Client (curl / Postman / Web App)
         │
         ▼
  API Gateway (HTTP API v2)
         │  (AWS_PROXY, payload v2.0)
         ▼
      Lambda (Python 3.11)
         │
         ▼
     DynamoDB (items table)
  • CORS enabled for * (customize later)
  • Access logs → CloudWatch Log Group /aws/apigw/<project>
  • Lambda logs → CloudWatch Log Group /aws/lambda/<function>
  • Throttling at stage level (burst/rate)

2) Prerequisites

  • AWS account with permissions to create: API Gateway v2 (HTTP), Lambda, IAM roles/policies, DynamoDB, CloudWatch Logs.
  • AWS CLI v2 configured (aws configure)
  • Terraform ≥ 1.6
  • Python ≥ 3.10 (for local packaging / edits)

Recommended region: us-east-1 (changeable via Terraform var).


3) Repo Layout

api-gateway-full-project/
├─ app/
│  └─ src/
│     └─ app.py                  # Lambda handler (CRUD)
├─ infra/
│  └─ terraform/
│     ├─ providers.tf
│     ├─ variables.tf
│     ├─ locals.tf
│     ├─ dynamodb.tf
│     ├─ lambda.tf
│     ├─ apigw_http.tf
│     ├─ outputs.tf
│     └─ versions.tf             # (optional) provider pins, see providers.tf
├─ Makefile                      # quality-of-life commands (optional)
└─ README.md                     # quickstart (this doc content)

Terraform zips the Lambda code automatically; no manual packaging needed.


4) Terraform – Infrastructure Code

Place all files below under infra/terraform/ unless otherwise noted.

providers.tf

terraform {
  required_version = ">= 1.6"
  required_providers {
    aws     = { source = "hashicorp/aws",    version = ">= 5.0" }
    archive = { source = "hashicorp/archive", version = ">= 2.4" }
    random  = { source = "hashicorp/random",  version = ">= 3.5" }
  }
}

provider "aws" {
  region = var.region
}

variables.tf

variable "region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Prefix for all resource names"
  type        = string
  default     = "apigw-crud"
}

locals.tf

locals {
  lambda_name      = "${var.project_name}-handler"
  api_log_group    = "/aws/apigw/${var.project_name}"
  lambda_log_group = "/aws/lambda/${local.lambda_name}"
}

# Useful identity/partition data
data "aws_caller_identity" "current" {}
data "aws_partition" "current" {}

dynamodb.tf

resource "aws_dynamodb_table" "items" {
  name         = "${var.project_name}-items"
  billing_mode = "PAY_PER_REQUEST"

  hash_key = "id"
  attribute {
    name = "id"
    type = "S"
  }

  point_in_time_recovery {
    enabled = true
  }

  tags = {
    Project = var.project_name
  }
}

lambda.tf

# Log group for Lambda with retention
resource "aws_cloudwatch_log_group" "lambda" {
  name              = local.lambda_log_group
  retention_in_days = 14
}

# Zip Lambda from repo's app/src directory
# NOTE: path.module points to infra/terraform, so ../.. goes to repo root
# Then /app/src is the code directory.
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/../../app/src"
  output_path = "${path.module}/lambda.zip"
}

resource "aws_iam_role" "lambda" {
  name = "${var.project_name}-lambda-role"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
      Action   = "sts:AssumeRole"
    }]
  })
}

# IAM policy: CloudWatch Logs + DynamoDB table access
resource "aws_iam_role_policy" "lambda" {
  name = "${var.project_name}-lambda-policy"
  role = aws_iam_role.lambda.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow",
        Action   = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ],
        Resource = "*"
      },
      {
        Effect = "Allow",
        Action = [
          "dynamodb:PutItem",
          "dynamodb:GetItem",
          "dynamodb:UpdateItem",
          "dynamodb:DeleteItem",
          "dynamodb:Scan"
        ],
        Resource = aws_dynamodb_table.items.arn
      }
    ]
  })
}

resource "aws_lambda_function" "main" {
  function_name    = local.lambda_name
  role             = aws_iam_role.lambda.arn
  filename         = data.archive_file.lambda_zip.output_path
  handler          = "app.lambda_handler"
  runtime          = "python3.11"
  timeout          = 10
  memory_size      = 256
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256

  environment {
    variables = {
      TABLE_NAME = aws_dynamodb_table.items.name
    }
  }

  depends_on = [aws_cloudwatch_log_group.lambda]
}

apigw_http.tf

# Access logs for API Gateway
resource "aws_cloudwatch_log_group" "api" {
  name              = local.api_log_group
  retention_in_days = 14
}

resource "aws_apigatewayv2_api" "http_api" {
  name          = "${var.project_name}-http"
  protocol_type = "HTTP"

  cors_configuration {
    allow_origins = ["*"]
    allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
    allow_headers = ["*"]
  }
}

resource "aws_apigatewayv2_integration" "lambda" {
  api_id                 = aws_apigatewayv2_api.http_api.id
  integration_type       = "AWS_PROXY"
  integration_uri        = aws_lambda_function.main.invoke_arn
  payload_format_version = "2.0"
  timeout_milliseconds   = 29000
}

# Catch‑all route to Lambda (you can add explicit routes later)
resource "aws_apigatewayv2_route" "default" {
  api_id    = aws_apigatewayv2_api.http_api.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}

resource "aws_apigatewayv2_stage" "default" {
  api_id      = aws_apigatewayv2_api.http_api.id
  name        = "$default"
  auto_deploy = true

  access_log_settings {
    destination_arn = aws_cloudwatch_log_group.api.arn
    format = jsonencode({
      requestId               = "$context.requestId",
      ip                      = "$context.identity.sourceIp",
      httpMethod              = "$context.httpMethod",
      routeKey                = "$context.routeKey",
      status                  = "$context.status",
      responseLength          = "$context.responseLength",
      integrationErrorMessage = "$context.integrationErrorMessage"
    })
  }

  default_route_settings {
    throttling_burst_limit = 100
    throttling_rate_limit  = 50
  }
}

# Allow API Gateway to invoke Lambda
resource "aws_lambda_permission" "apigw_invoke" {
  statement_id  = "AllowInvokeFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.main.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.http_api.execution_arn}/*"
}

outputs.tf

output "api_base_url" {
  description = "Invoke base URL for the HTTP API"
  value       = aws_apigatewayv2_api.http_api.api_endpoint
}

output "dynamodb_table" {
  value = aws_dynamodb_table.items.name
}

5) Lambda – app/src/app.py

import json
import os
import uuid
import boto3
from boto3.dynamodb.conditions import Key

TABLE_NAME = os.environ.get("TABLE_NAME")
ddb = boto3.resource("dynamodb")
items = ddb.Table(TABLE_NAME)

# Helpers

def _resp(status, body=None):
    return {
        "statusCode": status,
        "headers": {
            "Content-Type": "application/json",
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "*",
            "Access-Control-Allow-Headers": "*",
        },
        "body": json.dumps(body or {})
    }


def lambda_handler(event, context):
    # HTTP API v2.0 structure
    method = event.get("requestContext", {}).get("http", {}).get("method", "GET")
    raw_path = event.get("rawPath", "/")
    qs = event.get("queryStringParameters") or {}

    # Parse path: /items and /items/{id}
    parts = [p for p in raw_path.split('/') if p]
    resource = parts[0] if parts else ''
    item_id = parts[1] if len(parts) > 1 else None

    try:
        if resource != 'items':
            return _resp(404, {"message": "Not Found"})

        if method == 'GET' and item_id:
            # GET /items/{id}
            res = items.get_item(Key={"id": item_id})
            if 'Item' not in res:
                return _resp(404, {"message": "Item not found"})
            return _resp(200, res['Item'])

        if method == 'GET':
            # GET /items (scan; for production, consider query with indexes)
            res = items.scan(Limit=100)
            return _resp(200, res.get('Items', []))

        if method == 'POST':
            # POST /items (create)
            body = json.loads(event.get('body') or '{}')
            new_id = body.get('id') or str(uuid.uuid4())
            item = {
                'id': new_id,
                **{k: v for k, v in body.items() if k != 'id'}
            }
            items.put_item(Item=item)
            return _resp(201, item)

        if method == 'PUT' and item_id:
            # PUT /items/{id} (replace/merge)
            body = json.loads(event.get('body') or '{}')
            item = {'id': item_id, **body}
            items.put_item(Item=item)
            return _resp(200, item)

        if method == 'DELETE' and item_id:
            items.delete_item(Key={'id': item_id})
            return _resp(204, {})

        if method == 'OPTIONS':
            return _resp(200, {})

        return _resp(405, {"message": "Method Not Allowed"})

    except Exception as e:
        # Log full error in Lambda logs; return sanitized message to client
        print(f"ERROR: {e}")
        return _resp(500, {"message": "Internal Server Error"})

6) Deploy — Step by Step

From repo root (api-gateway-full-project/):

# 0) Optional: set region/profile
export AWS_REGION=us-east-1
export AWS_PROFILE=default   # or your profile

# 1) Initialize & plan
cd infra/terraform
terraform init
terraform plan -var "region=${AWS_REGION}" -out tfplan

# 2) Apply
terraform apply tfplan

# 3) Grab API URL
terraform output api_base_url
# Example: https://abc123.execute-api.us-east-1.amazonaws.com

Test with curl

API=$(terraform output -raw api_base_url)

# Create
curl -s -X POST "$API/items" \
  -H 'Content-Type: application/json' \
  -d '{"name":"first","status":"new"}' | jq .

# List
curl -s "$API/items" | jq .

# Get by id (replace <id>)
curl -s "$API/items/<id>" | jq .

# Update/replace
curl -s -X PUT "$API/items/<id>" \
  -H 'Content-Type: application/json' \
  -d '{"status":"updated"}' | jq .

# Delete
curl -s -X DELETE "$API/items/<id>" -i

Logs

  • API Gateway access logs: CloudWatch Log Group /aws/apigw/<project>
  • Lambda logs: CloudWatch Log Group /aws/lambda/<function>
aws logs tail "/aws/lambda/$(terraform output -raw dynamodb_table | sed "s/-items/-handler/")" --follow

Destroy

terraform destroy -auto-approve

7) Troubleshooting

  • 403 from API Gateway: Ensure aws_lambda_permission.apigw_invoke source_arn matches execution ARN pattern and region.
  • 5XX responses: Check Lambda logs in CloudWatch for stack trace.
  • Access denied on DynamoDB: Confirm Lambda IAM policy includes table ARN from Terraform (aws_dynamodb_table.items.arn).
  • CORS errors in browsers: CORS is enabled broadly; narrow as needed in aws_apigatewayv2_api.cors_configuration.

8) Optional Upgrades

  1. JWT auth (Amazon Cognito)

    • Create a User Pool, App Client.
    • Add aws_apigatewayv2_authorizer (JWT) with issuer https://cognito-idp.<region>.amazonaws.com/<user_pool_id> and audience [app_client_id].
    • Define explicit routes (e.g., GET /items, POST /items) with authorization_type = "JWT" and authorizer_id set.
  2. Custom domain

    • Request ACM certificate in the same region.
    • Create aws_apigatewayv2_domain_name, aws_apigatewayv2_api_mapping, and a Route53 A alias.
  3. WAF v2

    • Create aws_wafv2_web_acl and associate with the API stage if supported for your API type/region.
  4. CI/CD

    • Add GitHub Actions with AWS OIDC; run terraform fmt/validate/plan/apply on main branch.
  5. Observability

    • Emit structured app logs, add metrics via CloudWatch EMF, create alarms on 5XX and high latency.

9) Notes & Tradeoffs

  • This template uses HTTP API (v2) for lower cost and latency; if you need API keys/usage plans/mapping templates, consider REST API (v1).
  • The sample uses scan for listing items; for production, prefer Query with a GSI or well-designed keys.
  • Packaging uses the archive_file data source — for external dependencies, use a build step (e.g., pip install -t .) before zipping.

10) Makefile (optional convenience)

Place at repo root Makefile:

REGION ?= us-east-1
.PHONY: init plan apply destroy url
init:
	cd infra/terraform && terraform init
plan:
	cd infra/terraform && terraform plan -var "region=$(REGION)" -out tfplan
apply:
	cd infra/terraform && terraform apply tfplan
url:
	cd infra/terraform && terraform output api_base_url
destroy:
	cd infra/terraform && terraform destroy -auto-approve

You’re ready to build 🎯

Spin it up, hit the endpoints, and extend routes/logic as needed. For Authentication, custom domain, or REST API variant with API keys, start from the Optional Upgrades section.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors