GO AWS Development with Serverless Application Model-SAM to start functions locally
This is the third article of my AWS journey with Go language.
At the first article, I tried to discuss AWS introduction.
Then I moved to the more complicated staff Gorm, Object-Relational Model for Go and Amazon RDS PostgreSQL to handle create and get a user from the database.
But after this development, I need some extra features like running serverless functions at local. Also in my sample project, I have some common features like database connection, etc. If I work as the previous article, I need to redeploy each project with huge effort, when I changed shared code. After some research, I found the term Serverless Application Model. AWS SAM basically is an open-source framework to create serverless applications[1].
SAM has two important concepts. One of them is the template specification. Other is SAM CLI. Template specification is used to define your application. You can easily set your application specification which can be set manually at AWS Console. CLI is used to verify, start at local, package, deploy, etc. your application.
So at the below, I will try to discuss how to use SAM with minor effort.
First of all, you need to install SAM CLI. You can find a document at AWS Documents[2].
Creating sample project will be very helpful to see the project structure. Then you can move forward to more complex stuff.
Create Example Function with Sam
sam init --runtime go1.x --name helloworld
template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
helloworld
Sample SAM Template for helloworld# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
Function:
Timeout: 5Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: hello-world/
Handler: hello-world
Runtime: go1.x
Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
Events:
CatchAll:
Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
Properties:
Path: /hello
Method: GET
Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
Variables:
PARAM1: VALUEOutputs:
# ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
# Find out more about other implicit resources you can reference within SAM
# https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
HelloWorldAPI:
Description: "API Gateway endpoint URL for Prod environment for First Function"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
HelloWorldFunction:
Description: "First Lambda Function ARN"
Value: !GetAtt HelloWorldFunction.Arn
HelloWorldFunctionIamRole:
Description: "Implicit IAM Role created for Hello World function"
Value: !GetAtt HelloWorldFunctionRole.Arn
main.go
package mainimport (
"errors"
"fmt"
"io/ioutil"
"net/http""github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)var (
// DefaultHTTPGetAddress Default Address
DefaultHTTPGetAddress = "https://checkip.amazonaws.com"// ErrNoIP No IP found in response
ErrNoIP = errors.New("No IP in HTTP response")// ErrNon200Response non 200 status code in response
ErrNon200Response = errors.New("Non 200 Response found")
)func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
resp, err := http.Get(DefaultHTTPGetAddress)
if err != nil {
return events.APIGatewayProxyResponse{}, err
}if resp.StatusCode != 200 {
return events.APIGatewayProxyResponse{}, ErrNon200Response
}ip, err := ioutil.ReadAll(resp.Body)
if err != nil {
return events.APIGatewayProxyResponse{}, err
}if len(ip) == 0 {
return events.APIGatewayProxyResponse{}, ErrNoIP
}return events.APIGatewayProxyResponse{
Body: fmt.Sprintf("Hello, %v", string(ip)),
StatusCode: 200,
}, nil
}func main() {
lambda.Start(handler)
}
This function returns IP of https://checkip.amazonaws.com to the user.
SAM Local Start API
sam local start-api
Test your function
curl 127.0.0.1:3000/hello
The response is: Hello, 212.252.34.167
Sample project works fine. Let's customize to handle our needs.
Create Multiple Functions with Database Access and Local Start
At my previous post, I used the concept which includes creating and get users from the database. At this tutorial, I am going to use the same approach with some refactoring.
The signature of my handler function last time without SAM like below.
func HandleRequest(ctx context.Context, request CreateUserRequest) (model.User, error)
But when you init your function with SAM, more structured request and response types are required. For example, your return type must include body, status, etc parts. So I refactored my handle function like below.
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error)
If you return a response type which does not include body, status, etc SAM will give an error to you.
I have two functions to create and get users.
Create User Function
type CreateUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
postgresConnector := db.PostgresConnector{}
db2, dbErr := postgresConnector.GetConnection()
defer db2.Close()
if dbErr != nil {
return events.APIGatewayProxyResponse{}, errors.New(dbErr.Error())
}
var user model.User;
jsonErr := json.Unmarshal([]byte(request.Body), &user)
if jsonErr != nil {
return events.APIGatewayProxyResponse{}, errors.New(jsonErr.Error())
}
db2.AutoMigrate(&model.User{})
account := &model.User{}
account.Email = user.Email
account.Name = user.Name
db2.Create(account)
var jsonData []byte
jsonData, responseJsonErr := json.Marshal(account)
if responseJsonErr != nil {
return events.APIGatewayProxyResponse{}, errors.New(responseJsonErr.Error())
}
return events.APIGatewayProxyResponse{
Body: string(jsonData),
StatusCode: 200,
}, nil
}
func main() {
lambda.Start(handler)
}
In order to convert the incoming request to my request model, I need to use JSON unmarshal function. To return response body you are going to JSON marshal your model object. Because response.body requires accepts a string.
Get User Function
type GetUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
}
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
postgresConnector := db.PostgresConnector{}
db2, dbErr := postgresConnector.GetConnection()
defer db2.Close()
if dbErr != nil {
return events.APIGatewayProxyResponse{}, errors.New(dbErr.Error())
}
var user model.User;
jsonErr := json.Unmarshal([]byte(request.Body), &user)
if jsonErr != nil {
return events.APIGatewayProxyResponse{}, errors.New(jsonErr.Error())
}
db2.AutoMigrate(&model.User{})
account := &model.User{}
if user.Email != "" {
account.Email = user.Email
}
if user.Name != "" {
account.Name = user.Name
}
var users []model.User
db2.Where(account).Find(&users)
var jsonData []byte
jsonData, responseJsonErr := json.Marshal(users)
if responseJsonErr != nil {
return events.APIGatewayProxyResponse{}, errors.New(responseJsonErr.Error())
}
return events.APIGatewayProxyResponse{
Body: string(jsonData),
StatusCode: 200,
}, nil
}
func main() {
lambda.Start(handler)
}
Again you need to unmarshal coming request body. Then filter the result. Finally, marshal your response object.
Template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
helloworld
Sample SAM Template for helloworld
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
Function:
Timeout: 5
Resources:
createuser:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: createuser/
Handler: createuser
Runtime: go1.x
Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
Events:
CatchAll:
Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
Properties:
Path: /user
Method: POST
Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
Variables:
db_user: XXX
db_pass: XXX
db_name: XXX
db_host: XXX
getuser:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: getuser/
Handler: getuser
Runtime: go1.x
Tracing: Active # https://docs.aws.amazon.com/lambda/latest/dg/lambda-x-ray.html
Events:
CatchAll:
Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
Properties:
Path: /user
Method: GET
Environment: # More info about Env Vars: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#environment-object
Variables:
db_user: XXX
db_pass: XXX
db_name: XXX
db_host: XXX
Outputs:
# ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
# Find out more about other implicit resources you can reference within SAM
# https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
HelloWorldAPI:
Description: "API Gateway endpoint URL for Prod environment for First Function"
Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
CreateUser:
Description: "First Lambda Function ARN"
Value: !GetAtt CreateUser.Arn
CreateUserIamRole:
Description: "Implicit IAM Role created for Hello World function"
Value: !GetAtt CreateUserRole.Arn
Template.yaml is the most important file inside our folder. You have to state each function under resource. Path and method configurations are critical.
For creating user function, I used as below.
Properties:
Path: /user
Method: POST
For getting users function as below.
Properties:
Path: /user
Method: GET
My model is going to stay the same as below.
package model
import "github.com/jinzhu/gorm"
type User struct {
gorm.Model
Email string `json:"email"`
Name string `json:"name"`
}
Also, PostgreSQL DB connector is going to be same as below.
import (
"fmt"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/postgres"
"github.com/joho/godotenv"
"os"
)
type PostgresConnector struct {
}
func (p *PostgresConnector) GetConnection() (db *gorm.DB, err error) {
e := godotenv.Load()
if e != nil {
fmt.Print(e)
}
fmt.Print(os.Getenv("db_user"))
username := os.Getenv("db_user")
password := os.Getenv("db_pass")
dbName := os.Getenv("db_name")
dbHost := os.Getenv("db_host")
dbURI := fmt.Sprintf("host=%s user=%s dbname=%s sslmode=disable password=%s", dbHost, username, dbName, password)
return gorm.Open("postgres", dbURI)
}
You can see that my connector reads the required variable from the environment. In order to achieve these, you have to state your variable at template.yaml file. Yes true, you must write each variable at template.yaml and then you can create env.json and read from the file. If a variable only stated at env.json which is not written at template.yaml then your function could not see this variable.
From template.yaml:
Variables:
db_user: XXX
db_pass: XXX
db_name: XXX
db_host: XXX
From env.json
{
"createuser": {
"db_user": "XXX",
"db_pass": "XXX",
"db_name": "XXX",
"db_host": "XXX"
},"getuser":{
"db_user": "XXX",
"db_pass": "XXX",
"db_name": "XXX",
"db_host": "XXX"
}
}
Makefile
.PHONY: clean build
clean:
rm -rf ./createuser/createuser
rm -rf ./getuser/getuser
build:
GOOS=linux GOARCH=amd64 go build -gcflags='-N -l' -o createuser/createuser ./createuser
GOOS=linux GOARCH=amd64 go build -gcflags='-N -l' -o getuser/getuser ./getuser
Let’s test our function.
$make clean build
$sam local start-api --env-vars env.jsonMounting createuser at http://127.0.0.1:3000/user [POST]
Mounting getuser at http://127.0.0.1:3000/user [GET]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2019-09-14 16:53:47 * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
Create user:
curl -X POST \
http://localhost:3000/user \
-H 'Content-Type: application/json' \
-H 'cache-control: no-cache' \
-d '{
"email": "medium@test.com",
"name": "medium"
}'
Response:
{
"ID": 9,
"CreatedAt": "2019-09-14T13:54:56.032407541Z",
"UpdatedAt": "2019-09-14T13:54:56.032407541Z",
"DeletedAt": null,
"email": "medium@test.com",
"name": "medium"
}
Get User:
curl -X GET \
http://localhost:3000/user \
-H 'Content-Type: application/json' \
-H 'cache-control: no-cache' \
-d '{
"email": "",
"name": "medium"
}'
Response:
{
"ID": 9,
"CreatedAt": "2019-09-14T13:54:56.032407541Z",
"UpdatedAt": "2019-09-14T13:54:56.032407541Z",
"DeletedAt": null,
"email": "medium@test.com",
"name": "medium"
}
Debugging will be added later.
Package SAM template
$ sam package — template-file template.yaml — s3-bucket mybucket — output-template-file packaged.yaml
Deploy packaged SAM template
$ sam deploy — template-file ./packaged.yaml — stack-name mystack — capabilities CAPABILITY_IAM
Done, you can see functions at AWS Console.
Cites:
[1] https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/what-is-sam.html