In today’s world, serverless computing and Infrastructure as Code (IaC) have become game-changers for developers. This blog post will guide you through building a weather application using AWS Lambda and API Gateway, with all infrastructure managed by Terraform. This application fetches weather data from Tomorrow.io for a given city and stores it in DynamoDB. The entire project is written in Go, a powerful and efficient programming language. The code for this project can be found in my GitHub repo.
Prerequisites
Before we start, ensure you have the following prerequisites:
- An AWS account
- AWS CLI installed and configured
- Golang installed
- Terraform installed
Overview of the Project
The weather application leverages the following AWS services:
- AWS Lambda: Executes the application code in response to HTTP requests.
- API Gateway: Provides a RESTful API endpoint for the Lambda function.
- DynamoDB: Stores the fetched weather data.
- IAM Roles and Policies: Manages permissions for accessing AWS resources.
Project Structure
The project is organized as follows:
weather-app/
│
├── app/
│ ├── cmd/
│ │ └── main.go
│ ├── internal/
│ │ ├── handler/
│ │ │ └── handler.go
│ │ ├── weather/
│ │ │ └── weather.go
│ │ ├── cache/
│ │ │ └── cache.go
│ │ ├── db/
│ │ │ └── db.go
│ │ └── log/
│ │ └── log.go
│ ├── go.mod
│ ├── go.sum
│ ├── .env
│ ├── Makefile
│ └── lambda-handler.zip
│
├── devops/
│ ├── main.tf
│ ├── backend.tf
│ ├── variables.tf
│ └── outputs.tf
│
└── README.md
Setting Up Terraform Backend
Using Terraform to manage infrastructure state remotely is a best practice. We will use an S3 bucket and a DynamoDB table for state locking and consistency.
Step 1: Create an S3 Bucket and DynamoDB Table
Execute the following commands to set up the backend:
aws s3api create-bucket --bucket <unique_bucket_name> --region us-west-2 --create-bucket-configuration LocationConstraint=us-west-2
aws dynamodb create-table --table-name terraform-lock --attribute-definitions AttributeName=LockID,AttributeType=S --key-schema AttributeName=LockID,KeyType=HASH --billing-mode PAY_PER_REQUEST
This code creates a bucket to store the state and a dynamodb table to handle locking the state for deployments.
Step 2: Update the backend.tf
File
Update the backend.tf
file with your S3 bucket name and DynamoDB table name:
terraform {
backend "s3" {
bucket = "<unique_bucket_name>"
key = "weather-app/terraform.tfstate"
region = "us-west-2"
dynamodb_table = "terraform-lock"
}
}
Setting Up the Terraform Configuration
We’ll create the necessary AWS resources using Terraform. Below is the configuration for our infrastructure.
main.tf
provider "aws" {
region = "us-west-2"
}
data "aws_caller_identity" "current" {}
resource "aws_iam_role" "lambda_execution_role" {
name = "lambda-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Principal = {
Service = "lambda.amazonaws.com"
},
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_execution_policy_attachment" {
role = aws_iam_role.lambda_execution_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_policy" "lambda_dynamodb_policy" {
name = "lambda-dynamodb-policy"
description = "Policy for Lambda to access DynamoDB"
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Action = [
"dynamodb:PutItem",
"dynamodb:GetItem",
"dynamodb:DeleteItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:UpdateItem"
],
Resource = "arn:aws:dynamodb:us-west-2:${data.aws_caller_identity.current.account_id}:table/${var.DB_TABLE_NAME}"
}
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_dynamodb_policy_attachment" {
role = aws_iam_role.lambda_execution_role.name
policy_arn = aws_iam_policy.lambda_dynamodb_policy.arn
}
resource "aws_dynamodb_table" "weather_data" {
name = var.DB_TABLE_NAME
billing_mode = "PAY_PER_REQUEST"
hash_key = "City"
attribute {
name = "City"
type = "S"
}
}
resource "aws_lambda_function" "weather_app" {
function_name = "weather-app"
role = aws_iam_role.lambda_execution_role.arn
handler = "main"
runtime = "provided.al2023"
filename = "${path.module}/../app/lambda-handler.zip"
architectures = ["arm64"]
environment {
variables = {
DB_TABLE_NAME = aws_dynamodb_table.weather_data.name
API_KEY = var.WEATHER_API_KEY
VERSION = var.VERSION
}
}
}
resource "aws_apigatewayv2_api" "api" {
name = "weather-api"
protocol_type = "HTTP"
}
resource "aws_apigatewayv2_integration" "integration" {
api_id = aws_apigatewayv2_api.api.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.weather_app.arn
integration_method = "POST"
}
resource "aws_apigatewayv2_route" "route" {
api_id = aws_apigatewayv2_api.api.id
route_key = "GET /weather"
target = "integrations/${aws_apigatewayv2_integration.integration.id}"
}
resource "aws_lambda_permission" "apigw_lambda" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.weather_app.arn
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.api.execution_arn}/*"
}
resource "aws_apigatewayv2_stage" "stage" {
api_id = aws_apigatewayv2_api.api.id
name = "$default"
auto_deploy = true
}
variables.tf
variable "WEATHER_API_KEY" {
description = "API key for the Tomorrow.io API"
type = string
}
variable "DB_TABLE_NAME" {
description = "Name of the DynamoDB table to store weather data"
type = string
default = "weather-data"
}
variable "VERSION" {
description = "Version of the Lambda function"
type = string
}
outputs.tf
output "api_endpoint" {
description = "The URL endpoint for the API"
value = aws_apigatewayv2_stage.stage.invoke_url
}
backend.tf
terraform {
backend "s3" {
bucket = "your-terraform-state-bucket"
key = "path/to/your/terraform.tfstate"
region = "us-west-2"
}
}
Writing the Lambda Function
Next, let’s write the Lambda function in Go. This function handles requests from API Gateway, fetches weather data, caches the results, and stores the data in DynamoDB.
handler.go
package handler
import (
"context"
"encoding/json"
"fmt"
"weather-lambda/internal/cache"
"weather-lambda/internal/db"
"weather-lambda/internal/log"
"weather-lambda/internal/weather"
"github.com/aws/aws-lambda-go/events"
)
func HandleRequest(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
city := request.QueryStringParameters["city"]
// Sanitize city parameter
sanitizedCity := url.QueryEscape(city)
// Validate city
if sanitizedCity == "" {
log.Error("City parameter is required")
return events.APIGatewayProxyResponse{StatusCode: 400}, nil
}
// Check cache first
if cachedData, found := cache.GetCache(sanitizedCity); found {
log.Info(fmt.Sprintf("Returning cached data for city: %s", sanitizedCity))
return buildResponse(cachedData)
}
// Fetch weather data
weatherResponse, err := weather.FetchWeather(sanitizedCity)
if err != nil {
log.Error(fmt.Sprintf("Error fetching weather data: %v", err))
return events.APIGatewayProxyResponse{StatusCode: 500}, err
}
weatherData := weatherResponse.Data.Values
// Save to DynamoDB
dbData := db.WeatherData{
City: sanitizedCity,
Temperature: weatherData.Temperature,
Humidity: weatherData.Humidity,
}
if err := db.SaveWeatherData(dbData); err != nil {
log.Error(fmt.Sprintf("Error saving weather data to DynamoDB: %v", err))
return events.APIGatewayProxyResponse{StatusCode: 500}, err
}
// Cache the response
cache.SetCache(sanitizedCity, dbData)
log.Info(fmt.Sprintf("Returning new data for city: %s", sanitizedCity))
return buildResponse(dbData)
}
func buildResponse(data interface{}) (events.APIGatewayProxyResponse, error) {
body, err := json.Marshal(data)
if err != nil {
log.Error(fmt.Sprintf("Error marshalling response data: %v", err))
return events.APIGatewayProxyResponse{StatusCode: 500}, err
}
return events.APIGatewayProxyResponse{
StatusCode: 200,
Body: string(body),
}, nil
}
main.go
package main
import (
"github.com/aws/aws-lambda-go/lambda"
"weather-lambda/internal/handler"
)
func main() {
lambda.Start(handler.HandleRequest)
}
cache.go
package cache
import (
"fmt"
"time"
"weather-lambda/internal/log"
"github.com/patrickmn/go-cache"
)
var c = cache.New(5*time.Minute, 10*time.Minute)
func SetCache(key string, value interface{}) {
log.Info(fmt.Sprintf("Setting cache for key: %s", key))
c.Set(key, value, cache.DefaultExpiration)
}
func GetCache(key string) (interface{}, bool) {
data, found := c.Get(key)
if found {
log.Info(fmt.Sprintf("Cache hit for key: %s", key))
} else {
log.Info(fmt.Sprintf("Cache miss for key: %s", key))
}
return data, found
}
weather.go
package weather
import (
"encoding/json"
"fmt"
"net/http"
"os"
"weather-lambda/internal/log"
)
type WeatherDataValues struct {
CloudBase interface{} `json:"cloudBase"`
CloudCeiling interface{} `json:"cloudCeiling"`
CloudCover int `json:"cloudCover"`
DewPoint float64 `json:"dewPoint"`
FreezingRainIntensity int `json:"freezingRainIntensity"`
Humidity int `json:"humidity"`
PrecipitationProbability int `json:"precipitationProbability"`
PressureSurfaceLevel float64 `json:"pressureSurfaceLevel"`
RainIntensity int `json:"rainIntensity"`
SleetIntensity int `json:"sleetIntensity"`
SnowIntensity int `json:"snowIntensity"`
Temperature float64 `json:"temperature"`
TemperatureApparent float64 `json:"temperatureApparent"`
UVHealthConcern int `json:"uvHealthConcern"`
UVIndex int `json:"uvIndex"`
Visibility float64 `json:"visibility"`
WeatherCode int `json:"weatherCode"`
WindDirection float64 `json:"windDirection"`
WindGust float64 `json:"windGust"`
WindSpeed float64 `json:"windSpeed"`
}
type WeatherData struct {
Time string `json:"time"`
Values WeatherDataValues `json:"values"`
}
type WeatherLocation struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Name string `json:"name"`
Type string `json:"type"`
}
type WeatherResponse struct {
Data WeatherData `json:"data"`
Location WeatherLocation `json:"location"`
}
func FetchWeather(city string) (WeatherResponse, error) {
apiKey := os.Getenv("WEATHER_API_KEY")
if apiKey == "" {
return WeatherResponse{}, fmt.Errorf("WEATHER_API_KEY is required")
}
url := fmt.Sprintf("https://api.tomorrow.io/v4/weather/realtime?location=%s&apikey=%s", city, apiKey)
log.Info(fmt.Sprintf("Fetching weather data for city: %s", city))
req, _ := http.NewRequest("GET", url, nil)
req.Header.Add("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Error(fmt.Sprintf("Error making HTTP request: %v", err))
return WeatherResponse{}, err
}
defer resp.Body.Close()
log.Info(fmt.Sprintf("Received response with status code: %d", resp.StatusCode))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return WeatherResponse{}, fmt.Errorf("received response with status code: %d", resp.StatusCode)
}
log.Info(fmt.Sprintf("Response: %+v", resp))
var weatherResponse WeatherResponse
if err := json.NewDecoder(resp.Body).Decode(&weatherResponse); err != nil {
log.Error(fmt.Sprintf("Error decoding weather data: %v", err))
return WeatherResponse{}, err
}
log.Info(fmt.Sprintf("Successfully fetched weather data for city: %s", city))
return weatherResponse, nil
}
log.go
package log
import (
"log"
"os"
)
var (
infoLogger = log.New(os.Stdout, "INFO: ", log.Ldate|log.Ltime|log.Lshortfile)
errorLogger = log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)
)
func Info(msg string) {
infoLogger.Println(msg)
}
func Error(msg string) {
errorLogger.Println(msg)
}
db.go
package db
import (
"fmt"
"os"
"weather-lambda/internal/log"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)
type WeatherData struct {
City string `json:"City"`
Temperature float64 `json:"Temperature"`
Humidity int `json:"Humidity"`
}
func SaveWeatherData(data WeatherData) error {
sess := session.Must(session.NewSession(&aws.Config{
Region: aws.String(os.Getenv("AWS_REGION")),
}))
svc := dynamodb.New(sess)
av, err := dynamodbattribute.MarshalMap(data)
if err != nil {
log.Error(fmt.Sprintf("Error marshalling weather data: %v", err))
return err
}
input := &dynamodb.PutItemInput{
Item: av,
TableName: aws.String(os.Getenv("DB_TABLE_NAME")),
}
_, err = svc.PutItem(input)
if err != nil {
log.Error(fmt.Sprintf("Error saving weather data to DynamoDB: %v", err))
return err
}
log.Info(fmt.Sprintf("Successfully saved weather data for city: %s", data.City))
return nil
}
.env (example)
WEATHER_API_KEY=<your_tomorrow_io_api_key>
DB_TABLE_NAME=weather-data
Deploying the Lambda Function
Compile the Go code and create a zip file to deploy the Lambda function. For this particular project, we are building and deploying to the arm64 architecture.
GOOS=linux GOARCH=arm64 go build -o main main.go
zip lambda-handler.zip main
Applying the Terraform Configuration
Initialize and apply the Terraform configuration to deploy the resources.
terraform init
terraform apply
Testing the API
Once the resources are deployed, use the output api_endpoint
to make requests to your API.
curl -X GET "<api_endpoint>/weather?city=london"
Cleaning Up
To remove the Lambda function and associated resources, destroy the Terraform-managed infrastructure.
terraform destroy -auto-approve
Conclusion
This blog post walked you through setting up a weather application using AWS Lambda and API Gateway with Terraform. By following these steps, you can build a scalable, serverless application with Go and manage it using Infrastructure as Code.
Feel free to explore and modify the project. Contributions are welcome! If you encounter any issues or have suggestions for improvements, please open an issue or submit a pull request on the project’s GitHub repository.
Happy coding!