LAB18_AWS_REST API Gateway with Lambda Integration and Stage Deployment

REST API Gateway with Lambda Integration and Stage Deployment

☁ AWS ⭐ Beginner API Gateway Lambda Proxy Stage Deployment Method Request

Scenario

You have a Python Lambda function that returns a JSON greeting response and you want to expose it as a public HTTP endpoint. API Gateway REST API is the managed API layer that sits in front of Lambda — it handles routing, authentication, rate limiting, and SSL termination so you do not have to run a web server.

Create a REST API with a single GET /hello endpoint that triggers a Lambda function packaged from local Python source code, then deploy it to a named stage with throttling configured.

Your Objectives
  • Write a Python handler (lambda_src/handler.py) that reads a name query parameter and returns a JSON greeting with statusCode 200.
  • Use the archive_file data source to package the source directory into a zip and deploy it as an aws_lambda_function with its own IAM execution role.
  • Create an aws_api_gateway_rest_api with a /hello resource and a GET method using AWS_PROXY Lambda integration.
  • Grant lambda:InvokeFunction to API Gateway via aws_lambda_permission.
  • Create an aws_api_gateway_deployment with a hash-based trigger and a prod stage with throttling enabled.
  • Output the full invoke URL for the /hello endpoint.

Additional Context

In REST API Gateway with Terraform, a deployment must be recreated whenever the API configuration changes (new routes, methods, integrations). A common pattern is to add a triggers argument to aws_api_gateway_deployment containing a hash of all related resources — this forces a new deployment on every change.

The AWS_PROXY integration type passes the entire HTTP request to Lambda as an event — headers, body, query params, path, etc. Your Lambda handler must return a specific JSON shape with statusCode and body. Without this shape, API Gateway returns a 502 Bad Gateway.

lambda_src/handler.py

# lambda_src/handler.py  (Python source — not HCL)
import json

def lambda_handler(event, context):
    # API Gateway proxy passes query string params under queryStringParameters
    params = event.get("queryStringParameters") or {}
    name = params.get("name", "World")
    return {
        "statusCode": 200,
        "headers": {"Content-Type": "application/json"},
        "body": json.dumps({"message": f"Hello, {name}!"})
    }

versions.tf

terraform {
  required_version = ">= 1.5.0"
  required_providers {
    aws     = { source = "hashicorp/aws",     version = "~> 5.0" }
    archive = { source = "hashicorp/archive", version = "~> 2.4" }
  }
}

variables.tf

variable "aws_region"  { type = string; default = "us-east-1" }
variable "api_name"    { type = string; default = "hello-api" }
variable "environment" { type = string; default = "prod" }

main.tf

# main.tf

provider "aws" {
  region = var.aws_region
}

# Package the lambda_src/ directory into a zip file.
# Terraform re-zips on every plan. If source code changes, source_code_hash changes
# and Terraform triggers a Lambda update automatically.
data "archive_file" "lambda_zip" {
  type        = "zip"
  source_dir  = "${path.module}/lambda_src"
  output_path = "${path.module}/lambda.zip"
}

# Trust policy for Lambda.
data "aws_iam_policy_document" "lambda_assume" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals { type = "Service"; identifiers = ["lambda.amazonaws.com"] }
  }
}

# Lambda execution role — allows writing logs to CloudWatch.
resource "aws_iam_role" "lambda" {
  name               = "${var.api_name}-lambda-role"
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Lambda function — packages Python code from the local lambda_src/ directory.
resource "aws_lambda_function" "hello" {
  function_name    = "${var.api_name}-handler"
  runtime          = "python3.12"
  handler          = "handler.lambda_handler"   # filename.function_name
  role             = aws_iam_role.lambda.arn
  filename         = data.archive_file.lambda_zip.output_path
  source_code_hash = data.archive_file.lambda_zip.output_base64sha256
  depends_on       = [aws_iam_role_policy_attachment.lambda_logs]
}

# REST API.
resource "aws_api_gateway_rest_api" "main" {
  name        = var.api_name
  description = "Hello World REST API backed by Lambda"
  endpoint_configuration { types = ["REGIONAL"] }
}

# Resource — the /hello path.
resource "aws_api_gateway_resource" "hello" {
  rest_api_id = aws_api_gateway_rest_api.main.id
  parent_id   = aws_api_gateway_rest_api.main.root_resource_id
  path_part   = "hello"
}

# Method — GET /hello with no authentication (for this lab).
resource "aws_api_gateway_method" "get_hello" {
  rest_api_id   = aws_api_gateway_rest_api.main.id
  resource_id   = aws_api_gateway_resource.hello.id
  http_method   = "GET"
  authorization = "NONE"
}

# Lambda proxy integration — passes the entire HTTP request to Lambda.
# The integration HTTP method for Lambda is always POST internally.
resource "aws_api_gateway_integration" "lambda" {
  rest_api_id             = aws_api_gateway_rest_api.main.id
  resource_id             = aws_api_gateway_resource.hello.id
  http_method             = aws_api_gateway_method.get_hello.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.hello.invoke_arn
}

# Permission — API Gateway must be granted explicit permission to invoke Lambda.
# Without this, Lambda rejects API Gateway invocations with a 403.
resource "aws_lambda_permission" "apigw" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.hello.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.main.execution_arn}/*/*"
}

# Deployment — a snapshot of the API config at a point in time.
# triggers forces a new deployment when the integration or method changes.
resource "aws_api_gateway_deployment" "main" {
  rest_api_id = aws_api_gateway_rest_api.main.id

  triggers = {
    redeployment = sha1(jsonencode([
      aws_api_gateway_resource.hello.id,
      aws_api_gateway_method.get_hello.id,
      aws_api_gateway_integration.lambda.id,
    ]))
  }

  lifecycle {
    create_before_destroy = true
  }

  depends_on = [aws_api_gateway_integration.lambda]
}

# Stage — the callable version of the API (e.g., "prod").
resource "aws_api_gateway_stage" "prod" {
  deployment_id = aws_api_gateway_deployment.main.id
  rest_api_id   = aws_api_gateway_rest_api.main.id
  stage_name    = var.environment

  default_route_settings {
    throttling_rate_limit  = 100
    throttling_burst_limit = 200
  }
}

outputs.tf

output "invoke_url" {
  description = "Full URL to call the /hello endpoint."
  value       = "${aws_api_gateway_stage.prod.invoke_url}/hello"
}

Workflow Commands

mkdir -p lambda_src
# Create lambda_src/handler.py with the Python code shown above

terraform init
terraform apply

# Call the API
curl "$(terraform output -raw invoke_url)?name=Terraform"
# {"message": "Hello, Terraform!"}

terraform destroy
✓ Expected Output After Apply
Apply complete! Resources: 9 added, 0 changed, 0 destroyed.

Outputs:

invoke_url = "https://abc1234def.execute-api.us-east-1.amazonaws.com/prod/hello"