gRPC Web on AWS Lambda in 7 minutes

Posted on
grpc-web aws serverless go react typescript
Free Private gRPC API Documentation - Write less, generate more | Product Hunt

There are many reasons why we should use gRPC, but setting up the gRPC API might cause headaches. Today, we will show you how effortless deploying gRPC Web API to AWS Lambda is. It might get tricky as we’re transferring the binary data using AWS API Gateway.

gRPC Web is a project that aims to deliver a gRPC-like experience in a browser. As for now (2021), it is impossible to call gRPC API directly from the Web Browser - that’s why we need gRPC Web. You can quickly generate the Javascript or Typescript clients and use the API. Most of the new GenDocu services implement both gRPC and gRPC Web interfaces.

Before we start

We will build a simple client-server application for a library - it should create, delete or list the available books. We divided the tutorial into several sections:

  1. Defining the proto interface and generating the SDK
  2. Implementing the backend
  3. Implementing the frontend
  4. Deployment to AWS
  5. Bonus: Adding database support
  6. Additional troubleshooting First, four sections present how to implement a simple gRPC Web service. You can skip any section you want - we have prepared the ready implementation for each step. The whole repository is available on our GitHub. The API Documentation with SDK is available here.

API Definition

You can skip this step and use the generated SDK.

The whole app will consist of only one service - the Book Service.

//from https://github.com/gendocu-com-examples/library-app/blob/master/proto/service.proto
syntax = "proto3";
package gendocu.sdk_generator.v3;
// GenDocu automatically add language-specific options 
// - if you generate it manually, you need to add those to this file
import "google/protobuf/empty.proto";

// BookService is a simple service that enables listing, creating or removing the book.
service BookService {
   // ListBooks returns the list of all books available in the library without any pagination.
   rpc ListBooks(google.protobuf.Empty) returns (ListBookResponse);
   // DeleteBook deletes the book from library. Warning! This action cannot be revert and doesn't require any confirmation.
   rpc DeleteBook(DeleteBookRequest) returns (Book);
   // CreateBook creates a book in the library. We do not de-duplicate the requests as this is the tutorial API.
   rpc CreateBook(Book) returns (Book);
}

message DeleteBookRequest {
   oneof selector {
      string isbn = 1;
      string title = 2;
   }
}

message ListBookResponse {
   repeated Book books = 1;
}

message Book {
   string isbn = 1;
   string title = 2;
   Author author = 3;
}

message Author {
   string first_name = 1;
   string last_name = 2;
}

Next, we will generate the SDK using the GenDocu Console. We will use our protobuf definition available at https://github.com/gendocu-com-examples/library-app/ in proto directory. Create a new project in GenDocu Console, and then fill in the fields as in the image.

GenDocu Setup

Click “create project”. You should be redirected to the project board. After a couple of seconds, the API definition is processed.

GenDocu Complete Build

When the build is complete, you can click Public docs and then use the generated SDK.

Do you like this article? Subscribe newsletter to get more.

We use Mailchimp as our marketing platform. By clicking above to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. You can unsubscribe anytime. Learn more about Mailchimp's privacy practices here.

Backend implementation

You can skip this step and use our gRPC Web backend https://t30z1m0w81.execute-api.us-east-1.amazonaws.com.

Currently, GenDocu doesn’t generate the server-side code for gRPC, but we can easily write it on our own. To simplify our code, we have used the GenDocu’s generated SDK - you can fetch it from the Golang client code snippet. Create your Golang project and initialize it with go mod init command. Let’s create a simple HTTP server:

package main
import (
	"net/http"
)

func main() {
	if err := http.ListenAndServe(":8003", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request){
	  w.Write([]byte("Hello world"))
	})); err != nil {
		panic(err)
	}
}

We can run it using the command go run main.go.

The next step is to add the gRPC Web server. We need to install the SDK generated by GenDocu go get -u git.gendocu.com/gendocu/LibraryApp.git/sdk/go/.... After that, we can use the SDK:

package main

import (
	sdk "git.gendocu.com/gendocu/LibraryApp.git/sdk/go" // <-- sdk from gendocu
	"github.com/improbable-eng/grpc-web/go/grpcweb"
	"google.golang.org/grpc"
	"net/http"
)

func main() {
	grpcServer := grpc.NewServer()
	sdk.RegisterBookServiceServer(grpcServer, nil) // <--- this nil would be replaced the BookService implementation
	wrappedGrpc := grpcweb.WrapServer(grpcServer)
	if err := http.ListenAndServe(":8003", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request){
		wrappedGrpc.ServeHTTP(w, req)
	})); err != nil {
		panic(err)
	}
}

The last step is to implement the BookService and register it in the SDK. The good thing is that all the missing methods cause the compilation error.


import (
   "context"
   "fmt"
   sdk "git.gendocu.com/gendocu/LibraryApp.git/sdk/go"
   "github.com/golang/protobuf/ptypes/empty"
)
//...
type DummyService struct {
	books []*sdk.Book
}

func NewDummyService() *DummyService {
	return &DummyService{}
}
func (s *DummyService) ListBooks(ctx context.Context, empty *empty.Empty) (*sdk.ListBookResponse, error) {
	return &sdk.ListBookResponse{
		Books: s.books,
	}, nil
}

func (s *DummyService) DeleteBook(ctx context.Context, request *sdk.DeleteBookRequest) (*sdk.Book, error) {
	for ind, book := range s.books {
		if book.Isbn == request.GetIsbn() || book.Title == request.GetTitle(){
			//removing the book
			s.books[ind] = s.books[len(s.books)-1]
			s.books = s.books[:len(s.books)-1]
			return book, nil
		}
	}
	return nil, fmt.Errorf("book not found %+v", request)
}

func (s *DummyService) CreateBook(ctx context.Context, book *sdk.Book) (*sdk.Book, error) {
	s.books = append(s.books, book)
	return book, nil
}

Then we can register it in the main method:

srvc := NewDummyService()
sdk.RegisterBookServiceServer(grpcServer, srvc)

We have the whole gRPC Web server. We only need to add the CORS headers, and we can move on to the frontend app.

if err := http.ListenAndServe(":8003", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request){
     w.Header().Set("Access-Control-Allow-Origin", "*") //  <--  those lines
     w.Header().Set("Access-Control-Allow-Methods", "*") // <--  are 
     w.Header().Set("Access-Control-Allow-Headers", "*") // <--  new
     wrappedGrpc.ServeHTTP(w, req)
 })); err != nil {
     panic(err)
 }

Frontend implementation

You can skip this part and simply use the ready frontend app from our GitHub repository.

We can start from the snippet generated by GenDocu documentation. It contains the predefined remote URL address by default - let’s replace it with the local server localhost:8003. We will bootstrap our solution from the React template npx create-react-app my-app --template typescript. The next step is to replace the App.tsx file.

import React, {useEffect, useState} from 'react';

import {BookServiceClient} from "LibraryApp/sdk/ts/service_pb_service";
import {Empty} from "google-protobuf/google/protobuf/empty_pb";
import {DeleteBookRequest} from "LibraryApp/sdk/ts/service_pb";

var service0 = new BookServiceClient('http://localhost:8003') // <---- We replaced the url with the local server url
function App(){
  const [msgs, setMsgs] = useState<string[]>([])
  useEffect(() => {
    const req1 = new Empty();

    service0.listBooks(req1, (err, value) => {
      const msg = `listBooks() response err:${JSON.stringify(err)}, msg:${value}`
      setMsgs(old => !!old ? [...old, msg] : [msg])
    })
    const req2 = new DeleteBookRequest();

    service0.deleteBook(req2, (err, value) => {
      const msg = `deleteBook() response err:${JSON.stringify(err)}, msg:${value}`
      setMsgs(old => !!old ? [...old, msg] : [msg])
    })
  }, [])
  return <>
    <ol>
      {msgs.map(el => <li>{el}</li>)}
    </ol>
  </>
}
export default App;

To make it work, we need to install the generated Typescript SDK:

npm install git+https://git.gendocu.com/gendocu/LibraryApp.git#fd131dbbf77e73de26aed805930d6c2615ece495

After that, we need to add only the gRPC dependencies to allow us to use the protobuf types

npm install @improbable-eng/grpc-web google-protobuf @types/google-protobuf

VoilĂ ! We should be able to React app that communicates using gRPC Web. Run npm start to start the app.

Let’s add some form to add some books.

import {Book, Author} from "LibraryApp/sdk/ts/service_pb";
//...
  const addBook = () => {
    const req = new Book()
    req.setTitle(window.prompt("Title") || "")
    req.setIsbn(window.prompt("ISBN") || "")
    const author = new Author()
    author.setFirstName(window.prompt("Author's first name") || "")
    author.setLastName(window.prompt("Author's last name") || "")
    req.setAuthor(author)
    service0.createBook(req, (err, value) => {
    })
  }
return <>
    <ol>
        {msgs.map(el => <li>{el}</li>)}
    </ol>
    <input type="button" onClick={addBook} value="Add book"/>
</>

We also need to implement the book list functionality.

import {Empty} from "google-protobuf/google/protobuf/empty_pb";
//....
const listBooks = () => {
    service0.listBooks(new Empty(), (err, resp)=>{
        if(err !== null || resp === null) {
            console.error(err || "received empty response")
        } else {
            setBooks(resp.getBooksList())
        }
    })
}
useEffect(listBooks, [])
//...
return <>
    <ol>
        {books.map((el, key) => <li key={key}>{el.getTitle()} ({el.getIsbn()})</li>)}
    </ol>
    <input type="button" onClick={addBook} value="Add book"/>
</>

Now we can list all books, right after adding a new one:

  const addBook = () => {
    //.....
    service0.createBook(req, (err, value) => {
        listBooks()
    })
  }

You should be able to add a new book to our list. We can add “1984” with ISBN “9780151660346” by “George Orwell”.

Last but not least - deleting the book from the directory.

import {DeleteBookRequest} from "LibraryApp/sdk/ts/service_pb";
//...
const removeBook = (isbn:string) => {
    const req = new DeleteBookRequest()
    req.setIsbn(isbn)
    service0.deleteBook(req, (err, value)=>{
        if(err !== null || value === null){
            console.error("received error: ", (err || "got empty response"))
        }
        listBooks()
    })
}
//...
return <>
    <ol>
        {books.map((el, key) => <li key={key} onClick={() => removeBook(el.getIsbn())}>{el.getTitle()} ({el.getIsbn()})</li>)}
    </ol>
    <input type="button" onClick={addBook} value="Add book"/>
</>

Now, after clicking on the book title, we will remove it. It is not the best UX decision but should be fine for our example. Congratulations! You have now completely working local gRPC Web app. There is only one thing that we need to do to make it work with AWS Lambda backend - we need to obtain the backend address from the env variable:

var service0 = new BookServiceClient(process.env.REACT_APP_BACKEND || 'http://localhost:8003')

The complete code of the frontend is here. Now, let’s move on to deploying the backend to AWS lambda.

Deployment to AWS - Serverless framework

Before deploying our backend to AWS Lambda, we need to adjust the code. Instead of raw HTTP requests, we need to handle the events. We will convert those events to HTTP requests using the aws-lambda-go-api-proxy. That’s the new main function implementation:

package main

import (
   sdk "git.gendocu.com/gendocu/LibraryApp.git/sdk/go"
   "github.com/aws/aws-lambda-go/lambda"
   "github.com/awslabs/aws-lambda-go-api-proxy/handlerfunc"
   "github.com/gendocu-com-examples/library-app/backend/pkg"
   "github.com/improbable-eng/grpc-web/go/grpcweb"
   "google.golang.org/grpc"
   "log"
   "net/http"
)

func main() {
   grpcServer := grpc.NewServer()
   srvc := pkg.NewDummyService()
   sdk.RegisterBookServiceServer(grpcServer, srvc)
   wrappedGrpc := grpcweb.WrapServer(grpcServer)
   lambda.Start(handlerfunc.NewV2(func(w http.ResponseWriter, req *http.Request) {
      w.Header().Set("Access-Control-Allow-Origin", "*")
      w.Header().Set("Access-Control-Allow-Methods", "*")
      w.Header().Set("Access-Control-Allow-Headers", "*")
      if req.Method == http.MethodOptions {
         w.WriteHeader(http.StatusOK)
         return
      }
      wrappedGrpc.ServeHTTP(w, req)
   }).ProxyWithContext)
}

Deployment to AWS might be kind of tricky - gRPC Web requests contain a binary payload. We need to use the API Gateway HTTP API. To properly set it up, we will use the Serverless framework. Please install it using command npm install serverless@2.35.0. Before proceeding you need to set up the serverless framework - you can find more here. You can also implement those changes manually using the AWS Console.

This our deployment definition:

service: library-app
frameworkVersion: '2'
configValidationMode: error

provider:
   name: aws
   runtime: go1.x
   lambdaHashingVersion: 20201221
   iamRoleStatements:
      - Effect: Allow
        Action:
           - "cloudwatch:*"
        Resource: "arn:aws:logs:*:*:*"
   httpApi:
      cors:
         allowedOrigins:
            - '*'
         allowedHeaders:
            - "*"
         allowedMethods:
            - '*'
package:
   patterns:
      - "!**"
      - 'bin/lambda'
functions:
   hello:
      handler: bin/lambda
      events:
         - httpApi:
              path: '*'
              method: '*'

Next, run the command serverless deploy. After few minutes, you should obtain the lambda endpoint.

endpoints:
  ANY - https://t30z1m0w81.execute-api.us-east-1.amazonaws.com

Run the react app with the new endpoint: REACT_APP_BACKEND=https://t30z1m0w81.execute-api.us-east-1.amazonaws.com yarn start.

One problem with our app is that we store the book list in the memory in single lambda, and the received book list might be invalid.

Bonus: adding the database for persisting booklist

Our AWS Lambda doesn’t persist the book list - we store it in the memory, so we lose it when lambda is down. To resolve that issue, we will add the DynamoDB table using the serverless framework (or similar Cloudformation template):

#...
resources:
   Resources:
      ApiDoc:
         Type: 'AWS::DynamoDB::Table'
         DeletionPolicy: Retain
         Properties:
            StreamSpecification:
               StreamViewType: NEW_IMAGE
            AttributeDefinitions:
               - AttributeName: 'Isbn' # time ordered uuid
                 AttributeType: S
            KeySchema:
               - AttributeName: 'Isbn'
                 KeyType: HASH
            BillingMode: PAY_PER_REQUEST
            TableName: ExampleLibraryApp

and a permission for our lambda to read the table:

#...
provider:
  name: aws
  runtime: go1.x
  lambdaHashingVersion: 20201221
  iamRoleStatements:
    - Effect: Allow
      Action:
        - dynamodb:*
      Resource:
        - "arn:aws:dynamodb:*"
#...

We need to adjust our backend implementation. Let’s implement the new version of our Book Service:

package pkg

import (
	"context"
	"fmt"
	sdk "git.gendocu.com/gendocu/LibraryApp.git/sdk/go"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/golang/protobuf/ptypes/empty"
	"github.com/guregu/dynamo"
)

type DynamoDBService struct {
	t dynamo.Table
}

func NewDynamoDBService() *DynamoDBService {
	sess := session.Must(session.NewSessionWithOptions(session.Options{
		SharedConfigState: session.SharedConfigEnable,
	}))
	db := dynamo.New(sess)
	return &DynamoDBService{
		t: db.Table("ExampleLibraryApp"),
	}
}
func (s *DynamoDBService) ListBooks(ctx context.Context, empty *empty.Empty) (*sdk.ListBookResponse, error) {
	var res []*sdk.Book
	if err := s.t.Scan().All(&res); err != nil {
		return nil, err
	}
	return &sdk.ListBookResponse{
		Books: res,
	}, nil
}

func (s *DynamoDBService) DeleteBook(ctx context.Context, request *sdk.DeleteBookRequest) (*sdk.Book, error) {
	var res *sdk.Book
	if err := s.t.Get("Isbn", request.GetIsbn()).OneWithContext(ctx, &res); err != nil {
		return nil, err
	}
	fmt.Println("got that book", res)
	err := s.t.Delete("Isbn", request.GetIsbn()).RunWithContext(ctx)
	return res, err
}

func (s *DynamoDBService) CreateBook(ctx context.Context, book *sdk.Book) (*sdk.Book, error) {
	err := s.t.Put(book).RunWithContext(ctx)
	return book, err
}

With that simple change, we’re storing our booklist in the DynamoDB Database. That’s all!

Do you like this article? Subscribe newsletter to get more.

We use Mailchimp as our marketing platform. By clicking above to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. You can unsubscribe anytime. Learn more about Mailchimp's privacy practices here.

Troubleshooting

Backend

  1. grpc: server failed to encode response: rpc error: code = Internal desc = grpc: error while marshaling: proto: Marshal called with nil. You are possibly returning the nil instead of empty.Empty struct. e.g. replace
func (s DummyService) CreateBook(ctx context.Context, book *sdk.Book) (*sdk.Book, error) {
	s.books = append(s.books, book)
	return nil, nil
}

with

import "github.com/golang/protobuf/ptypes/empty"

//....

func (s DummyService) CreateBook(ctx context.Context, book *sdk.Book) (*sdk.Book, error) {
	s.books = append(s.books, book)
	return book, nil
}
  1. gRPC requires HTTP/2. AWS API Gateway is handling HTTP 2.0 in a tricky way. All options requests are passed to lambda as HTTP 1.1 - gRPC requires using HTTP 2.0 to properly handle that. We have simply early return on options request. POST requests are passed properly as HTTP 2.0.

  2. Error: Response closed without grpc-status (Headers only). When there is an error, the gRPC Web doesn’t return a body but sets a specific message and code in the headers. Please check whether your function returns the headers (in AWS Lambda settings, in the test section) - grpc-status should be set and be returned in AWS event as headers (not multiValueHeaders), e.g.

{
  "statusCode": 404,
  "headers": {
    "Access-Control-Allow-Credentials": "true",
    "Access-Control-Allow-Headers": "*",
    "Access-Control-Allow-Methods": "*",
    "Access-Control-Allow-Origin": "*",
    "Content-Type": "text/plain; charset=utf-8",
    "grpc-status": "3"
  },
  "multiValueHeaders": null,
  "body": "requested path: https:///?foo=bar",
  "cookies": null
}

For Golang, there was an issue with the github.com/awslabs/aws-lambda-go-api-proxy that converts http response to API Gateway event - since version 0.10.1 it works fine.

Frontend

  1. Error: Response closed without headers. There are multiple reasons for that error message. Please check in the browser console the request:

    • if you can’t find it request log you have possibly misspelled the backend url. Please remember, that you need to provide the whole url including the protocol scheme. e.g. http://localhost:8003 instead of localhost:8003
    • check the protocol scheme - it is important to correctly distinguish the http endpoint from https endpoint
    • check the response body - you may receive the malformed output data, e.g. json instead of gRPC Web or double base64 encoded data (it is quite common for AWS API Gateway v1)
    • check the whole service url - you may misspell it, like http://localhost:8030 instead of http://localhost:8003. There should be no trailing slash - http://localhost:8003/ is invalid.
  2. Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at http://localhost:8003/gendocu.sdk_generator.v3.BookService/ListBooks. (Reason: CORS request did not succeed).. You need to CORS headers, like:

http.HandlerFunc(func(w http.ResponseWriter, req *http.Request){
   w.Header().Set("Access-Control-Allow-Origin", "*")
   w.Header().Set("Access-Control-Allow-Methods", "*")
   w.Header().Set("Access-Control-Allow-Headers", "*")
   // handle request
})

We strongly advise to not set those headers' values to ‘*’. You can read more about the correct values here.

Deployment

  1. No file matches include / exclude patterns. There is possibly no generated file in the folder included in the serverless.yaml file. In our example, it is bin/lambda compiled binary. Please check whether the directory contains such a file. If you doubt, you can check out our repository and call make deploy to deploy our example. Then you compare those both projects and find the difference.

Do you like this article? Subscribe newsletter to get more.

We use Mailchimp as our marketing platform. By clicking above to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. You can unsubscribe anytime. Learn more about Mailchimp's privacy practices here.