[DEV PART 2/2] Serverless Highscore Go API with Faasd and CockroachDB

mrwormhole

Talha Altınel

Posted on September 8, 2021

[DEV PART 2/2] Serverless Highscore Go API with Faasd and CockroachDB

The Intro

    Hi everyone, this is the 2nd part of the series, we will be developing our API in this part. I will assume you have already followed the previous part and setup faasd and CockroachDB in your cloud server instance and have faas-cli in your both client computer and cloud server instance. I will also assume you have Go on your computer and a proper text editor. Let's quickly get started.

highscore-api-github-repo

Requirements:

  • Go knowledge
  • docker hub account
  • faas-cli
  • up and running faasd server
  • basic SQL knowledge

First, we would like to make sure your faas-cli works correctly in your server, you should already know your server IP address, your username and your password for faasd. Let's see if the server instance validates us.

faas-cli login -g http://23.88.60.124:8080 -u admin -p jackthegiant
Enter fullscreen mode Exit fullscreen mode

faas-cli_login_command

Faasd Project Init

faas-cli template store pull golang-http
faas-cli new --lang golang-http get-highscores
Enter fullscreen mode Exit fullscreen mode

faasd-cli_project_command
The above command will create a yml file and a function handler that we will have to adjust for faasd. As an initial clean up, I will rename my get-highscores.yml to stack.yml, this file will contain our functions for faasd. It is general practice to have it as stack.yml because you will need 1 less flag during faas-cli up -f filename.yml

I will also change the provider's gateway to my server cloud instance which is http://[[SERVER_IP]]:8080.In my case, It is http://23.88.60.124:8080.

The other most important part is to give your docker hub container name to image names and turn on go modules in environment variables. Here is what it looks like after tidying up stack.yml. Make sure you login to your docker hub account and create a repository there first

version: 1.0
provider:
  name: openfaas
  gateway: http://23.88.60.124:8080
functions:
  get-highscores:
    lang: golang-http
    handler: ./get-highscores
    image: mrwormhole/get-highscores:latest
    build_args:
      GO111MODULE: on
    environment:
      POSTGRES_HOST: 23.88.60.124
      POSTGRES_PORT: 26257
      POSTGRES_USER: root
      POSTGRES_DB: highscore_db
Enter fullscreen mode Exit fullscreen mode

docker-hub-repo-creation

Now you have the initial configuration setup, let's deploy your generated handler to see if it is getting deployed. Your template code should look like this. The best part is now you can deploy very easily with a single command. This single up command will build your container(faas-cli build), deploy your code to the container registry(faas-cli push) then pull that container to your
cloud server(faas-cli deploy) instance.

get-highscores/handler.go

package function

import (
    "fmt"
    "net/http"

    handler "github.com/openfaas/templates-sdk/go-http"
)

// Handle a function invocation
func Handle(req handler.Request) (handler.Response, error) {
    var err error

    message := fmt.Sprintf("Body: %s", string(req.Body))

    return handler.Response{
        Body:       []byte(message),
        StatusCode: http.StatusOK,
    }, err
}
Enter fullscreen mode Exit fullscreen mode
docker login
faas-cli up
Enter fullscreen mode Exit fullscreen mode

docker-loginfaas-cli-up

You can additionally use faas-cli list to see running functions. Now I will grab sqlc to generate a repository layer for our Go function handler. To use sqlc, you will install its CLI, sqlc.json file which will point to our queries.sql and schema.sql

go get github.com/kyleconroy/sqlc/cmd/sqlc
Enter fullscreen mode Exit fullscreen mode

Here is how my sqlc.json, schema.sql and queries.sql look like. If you don't know basic SQL, I strongly suggest you to visit W3C SQL docs for quick recap and have a look at sqlc docs

sqlc.json

{
  "version": "1",
  "packages": [
    {
      "path": "repository",
      "name": "repository",
      "queries": "queries.sql",
      "schema": "schema.sql"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

schema.sql

CREATE TABLE highscores (
  id BIGSERIAL PRIMARY KEY,
  username TEXT NOT NULL UNIQUE,
  score BIGINT NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

queries.sql

-- name: GetHighscore :one
SELECT * FROM highscores
WHERE username = $1 LIMIT 1;

-- name: ListHighscores :many
SELECT * FROM highscores
ORDER BY score;

-- name: CreateHighscore :one
INSERT INTO highscores(username, score) 
VALUES ($1, $2) RETURNING *;

-- name: UpdateHighscore :one
UPDATE highscores
SET score = $2
WHERE id = $1 RETURNING *;

-- name: DeleteHighscore :exec
DELETE FROM highscores
WHERE username = $1;
Enter fullscreen mode Exit fullscreen mode

Now we can generate our repository layer since we have completed all of the database interactions. The below command will generate all of the repository code for Go from SQL.

sqlc generate
Enter fullscreen mode Exit fullscreen mode

I will initialize go modules and get pq which is a pure Go postgres driver. Why do we use postgres driver for CockroachDB? CockroachDB supports PostgreSQL wire protocol. This means it is almost fully compatible with postgres drivers and ORMs.

go mod init github.com/mrwormhole/highscore-api
go get github.com/lib/pq
Enter fullscreen mode Exit fullscreen mode

Let's finish up our handler for get-highscores. I will establish a database connection and check for the correct HTTP method. I will also check if there is a username query for the highscore. If yes, I will return a specific user's highscore. Otherwise, I will return all of the highscores in the database. Please make sure to import lib/pq manually.

get-highscores/handler.go

package function

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "strings"

    _ "github.com/lib/pq"
    "github.com/mrwormhole/highscore-api/repository"
    handler "github.com/openfaas/templates-sdk/go-http"
)

func Handle(req handler.Request) (handler.Response, error) {
    db, err := sql.Open("postgres", fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable",
        os.Getenv("POSTGRES_HOST"),
        os.Getenv("POSTGRES_PORT"),
        os.Getenv("POSTGRES_USER"),
        os.Getenv("POSTGRES_DB")))
    defer func() {
        err = db.Close()
        if err != nil {
            log.Printf("failed to close db: %v", err)
        }
    }()
    if err != nil {
        return handler.Response{
            StatusCode: http.StatusInternalServerError,
        }, fmt.Errorf("failed to connect to db: %v", err)
    }
    if req.Method != http.MethodGet {
        return handler.Response{
            StatusCode: http.StatusBadRequest,
        }, fmt.Errorf("invalid http method %s", req.Method)
    }

    values, err := url.ParseQuery(req.QueryString)
    if err != nil {
        return handler.Response{
            StatusCode: http.StatusInternalServerError,
        }, fmt.Errorf("failed to parse query string: %v", err)
    }

    var rawBody []byte
    queries := repository.New(db)
    username := values.Get("username")

    if strings.TrimSpace(username) != "" {
        highscore, err := queries.GetHighscore(req.Context(), username)
        if err != nil {
            if err == sql.ErrNoRows {
                return handler.Response{
                    StatusCode: http.StatusNotFound,
                }, nil
            }
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to get highscore for username %s: %v", username, err)
        }

        rawBody, err = json.Marshal(highscore)
        if err != nil {
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to marshal a highscore: %v", err)
        }
    } else {
        highscores, err := queries.ListHighscores(req.Context())
        if err != nil {
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to list highscores: %v", err)
        }

        rawBody, err = json.Marshal(highscores)
        if err != nil {
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to marshal highscores: %v", err)
        }
    }

    return handler.Response{
        Body:       rawBody,
        StatusCode: http.StatusOK,
    }, nil
}

Enter fullscreen mode Exit fullscreen mode

Now I will create my second function and create its docker hub repo and tidy up stack.yml. I will also add a token credential so that not everyone can add highscore to my database.

faas-cli new --lang golang-http post-highscore --append stack.yml
Enter fullscreen mode Exit fullscreen mode
version: 1.0
provider:
  name: openfaas
  gateway: http://23.88.60.124:8080
functions:
  get-highscores:
    lang: golang-http
    handler: ./get-highscores
    image: mrwormhole/get-highscores:latest
    build_args:
      GO111MODULE: on
    environment:
      POSTGRES_HOST: 23.88.60.124
      POSTGRES_PORT: 26257
      POSTGRES_USER: root
      POSTGRES_DB: highscore_db

  post-highscore:
    lang: golang-http
    handler: ./post-highscore
    image: mrwormhole/post-highscore:latest
    build_args:
      GO111MODULE: on
    environment:
      POSTGRES_HOST: 23.88.60.124
      POSTGRES_PORT: 26257
      POSTGRES_USER: root
      POSTGRES_DB: highscore_db
      BEARER_TOKEN: QeV5f7eSvJnO0dDYCc9DcH5BEwpm7P3j
Enter fullscreen mode Exit fullscreen mode

I will create a package called model and middleware. My model will only contain how a request should look like and my middleware will look like a basic auth header check against our specified BEARER_TOKEN env variable.

model/highscore.go

package model

type Highscore struct {
    Username string `json:"username"`
    Score    int64  `json:"score"`
}
Enter fullscreen mode Exit fullscreen mode

middleware/auth.go

package middleware

import (
    "errors"
    "os"
    "strings"

    handler "github.com/openfaas/templates-sdk/go-http"
)

func Authorization(req handler.Request) error {
    authHeader := req.Header.Get("Authorization")
    authHeaderValues := strings.Split(authHeader, " ")
    if len(authHeaderValues) != 2 || authHeaderValues[0] != "Bearer" {
        return errors.New("authorization header is in the wrong format")
    }
    if authHeaderValues[1] != os.Getenv("BEARER_TOKEN") {
        return errors.New("bearer token is not valid")
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Finishing up the handler for post-highscore. I will establish a database connection and check for the correct HTTP method. I will check for the authorization header. If there are no users with that username, we will create a new one and return that in the body. If there is someone with that username, we will check the incoming request's highscore and compare it with the one that highscore that is persisted. If that is higher, we can go ahead and update then return that in the body. Otherwise, we return empty 200 to the request.

post-highscore/handler.go

package function

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"

    _ "github.com/lib/pq"
    "github.com/mrwormhole/highscore-api/middleware"
    "github.com/mrwormhole/highscore-api/model"
    "github.com/mrwormhole/highscore-api/repository"
    handler "github.com/openfaas/templates-sdk/go-http"
)

func Handle(req handler.Request) (handler.Response, error) {
    db, err := sql.Open("postgres", fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable",
        os.Getenv("POSTGRES_HOST"),
        os.Getenv("POSTGRES_PORT"),
        os.Getenv("POSTGRES_USER"),
        os.Getenv("POSTGRES_DB")))
    defer func() {
        err = db.Close()
        if err != nil {
            log.Printf("failed to close db: %v", err)
        }
    }()
    if err != nil {
        return handler.Response{
            StatusCode: http.StatusInternalServerError,
        }, fmt.Errorf("failed to connect to db: %v", err)
    }
    if req.Method != http.MethodPost {
        return handler.Response{
            StatusCode: http.StatusBadRequest,
        }, fmt.Errorf("invalid http method %s", req.Method)
    }

    err = middleware.Authorization(req)
    if err != nil {
        return handler.Response{
            StatusCode: http.StatusBadRequest,
        }, fmt.Errorf("%v", err)
    }

    var highscore model.Highscore
    err = json.Unmarshal(req.Body, &highscore)
    if err != nil {
        return handler.Response{
            StatusCode: http.StatusInternalServerError,
        }, fmt.Errorf("failed to unmarshal highscore")
    }

    queries := repository.New(db)
    existingHighscore, err := queries.GetHighscore(req.Context(), highscore.Username)
    if err != nil && err != sql.ErrNoRows {
        return handler.Response{
            StatusCode: http.StatusInternalServerError,
        }, fmt.Errorf("failed to get a highscore: %v", err)
    }

    if existingHighscore.ID == 0 {
        params := repository.CreateHighscoreParams{Username: highscore.Username, Score: highscore.Score}
        createdHighscore, err := queries.CreateHighscore(req.Context(), params)
        if err != nil {
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to create a highscore: %v", err)
        }

        raw, err := json.Marshal(createdHighscore)
        if err != nil {
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to marshal created highscore")
        }

        return handler.Response{
            Body:       []byte(raw),
            StatusCode: http.StatusOK,
        }, nil
    }

    if highscore.Score > existingHighscore.Score {
        params := repository.UpdateHighscoreParams{ID: existingHighscore.ID, Score: highscore.Score}
        updatedHighscore, err := queries.UpdateHighscore(req.Context(), params)
        if err != nil {
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to update a highscore: %v", err)
        }

        raw, err := json.Marshal(updatedHighscore)
        if err != nil {
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to marshal updated highscore")
        }

        return handler.Response{
            Body:       []byte(raw),
            StatusCode: http.StatusOK,
        }, nil
    }

    return handler.Response{
        StatusCode: http.StatusOK,
    }, nil
}

Enter fullscreen mode Exit fullscreen mode

Now I will create my third and final handler and respectively its docker hub repo. I will add a token credential to this handler as well. Because not everyone needs to delete someone else's highscore :) your final yaml structure is given below.

faas-cli new --lang golang-http delete-highscore --append stack.yml
Enter fullscreen mode Exit fullscreen mode
version: 1.0
provider:
  name: openfaas
  gateway: http://23.88.60.124:8080
functions:
  get-highscores:
    lang: golang-http
    handler: ./get-highscores
    image: mrwormhole/get-highscores:latest
    build_args:
      GO111MODULE: on
    environment:
      POSTGRES_HOST: 23.88.60.124
      POSTGRES_PORT: 26257
      POSTGRES_USER: root
      POSTGRES_DB: highscore_db

  post-highscore:
    lang: golang-http
    handler: ./post-highscore
    image: mrwormhole/post-highscore:latest
    build_args:
      GO111MODULE: on
    environment:
      POSTGRES_HOST: 23.88.60.124
      POSTGRES_PORT: 26257
      POSTGRES_USER: root
      POSTGRES_DB: highscore_db
      BEARER_TOKEN: QeV5f7eSvJnO0dDYCc9DcH5BEwpm7P3j

  delete-highscore:
    lang: golang-http
    handler: ./delete-highscore
    image: mrwormhole/delete-highscore:latest
    build_args:
      GO111MODULE: on
    environment:
      POSTGRES_HOST: 23.88.60.124
      POSTGRES_PORT: 26257
      POSTGRES_USER: root
      POSTGRES_DB: highscore_db
      BEARER_TOKEN: Ru4BXyL7ALkey34cUJIIXBF67t1qrw37
Enter fullscreen mode Exit fullscreen mode

This handler will also handle its database connection and validate the authorization header then check the username in the URL query. Afterward, we delete the highscore that matches that username.

delete-highscore/handler.go

package function

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "net/url"
    "os"
    "strings"

    _ "github.com/lib/pq"
    "github.com/mrwormhole/highscore-api/middleware"
    "github.com/mrwormhole/highscore-api/repository"
    handler "github.com/openfaas/templates-sdk/go-http"
)

func Handle(req handler.Request) (handler.Response, error) {
    db, err := sql.Open("postgres", fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable",
        os.Getenv("POSTGRES_HOST"),
        os.Getenv("POSTGRES_PORT"),
        os.Getenv("POSTGRES_USER"),
        os.Getenv("POSTGRES_DB")))
    defer func() {
        err = db.Close()
        if err != nil {
            log.Printf("failed to close db: %v", err)
        }
    }()
    if err != nil {
        return handler.Response{
            StatusCode: http.StatusInternalServerError,
        }, fmt.Errorf("failed to connect to db: %v", err)
    }
    if req.Method != http.MethodDelete {
        return handler.Response{
            StatusCode: http.StatusBadRequest,
        }, fmt.Errorf("invalid http method %s", req.Method)
    }

    err = middleware.Authorization(req)
    if err != nil {
        return handler.Response{
            StatusCode: http.StatusBadRequest,
        }, fmt.Errorf("%v", err)
    }

    values, err := url.ParseQuery(req.QueryString)
    if err != nil {
        return handler.Response{
            StatusCode: http.StatusInternalServerError,
        }, fmt.Errorf("failed to parse query string: %v", err)
    }

    queries := repository.New(db)
    username := values.Get("username")

    if strings.TrimSpace(username) != "" {
        err = queries.DeleteHighscore(req.Context(), username)
        if err != nil {
            if err == sql.ErrNoRows {
                return handler.Response{
                    StatusCode: http.StatusNotFound,
                }, nil
            }
            return handler.Response{
                StatusCode: http.StatusInternalServerError,
            }, fmt.Errorf("failed to delete a highscore for username %s: %v", username, err)
        }
    }

    return handler.Response{
        StatusCode: http.StatusOK,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

Now we can do faas-cli up and see the deployed functions. You can also check out the dashboard to get the endpoint names.
all-funcs-deployed

If you are getting internal server error 500, that means you are returning an error to the function handler and you can easily debug your server. For example, I am returning an error for invalid HTTP methods. I can easily see logs with this command

journalctl -t openfaas-fn:get-highscores -r --lines 20
Enter fullscreen mode Exit fullscreen mode

checking-faasd-logs

The end

These are the endpoints we have created. Overall, I enjoyed how we can have a serverless developer experience without the need for any giant cloud service that is impossible to move around. Faasd is still a young but promising project for developers who don't want to deal with k8s infra complexity. Hope you enjoyed and learned something new. If you have any questions/issues, feel free to let me know. Take care!

💖 💪 🙅 🚩
mrwormhole
Talha Altınel

Posted on September 8, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related