NAV

Aegis

An AWS Serverless Framework for Go

Routers

First things first. The AWS Lambda Go package is used by Aegis. In fact, it’s basically required for native Go support in Lambda. I don’t think anyone wants to write a competing package (that’d be silly). However, that package is designed to be lightweight. It does not include a bunch of creature comforts. That’s where Aegis routers come in handy.

Above all else, there is a default handler that can be used for any incoming Lambda event. This is called the DefaultHandler. Aegis routers aim to make life easier without hiding all of the unknowns in life. You can always handle incoming JSON event messages as maps.

However, there’s some very helpful routers to help direct events to your handlers. You’re probably looking to use a framework because you don’t want to write all that logic yourself, right?

One last quick note: All of the routers you see below can be used on the same Lambda function. Aegis does not restrict your Lambda to handling just one type of event. The design of your functions is entirely up to you.

API Gateway Router

This should look familiar if you’ve built an HTTP RESTful API in Go before

package main

import aegis "github.com/tmaiaroto/aegis/framework"

func main() {
    // Handle an APIGatewayProxyRequest event with a URL request path Router
    router := aegis.NewRouter(fallThrough)
    router.Handle("GET", "/", handleRoot)

    // Register the handler
    app := aegis.New(aegis.Handlers{
        Router: router,
    })
    // A blocking call that listens for events
    app.Start()
}

// fallThrough handles any path that couldn't be matched to another handler
func fallThrough(ctx context.Context, d *aegis.HandlerDependencies, req *aegis.APIGatewayProxyRequest, res *aegis.APIGatewayProxyResponse, params url.Values) error {
    res.StatusCode = 404
    return nil
}

// handleRoot is handling GET "/" in this case
func handleRoot(ctx context.Context, d *aegis.HandlerDependencies, req *aegis.APIGatewayProxyRequest, res *aegis.APIGatewayProxyResponse, params url.Values) error {
    res.JSON(200, map[string]interface{}{"event": req})
    return nil
}

Perhaps the most common event handler is for API Gateway requests. When you think about a router for a web application, typically handling HTTP requests comes to mind. So the Aegis’ router for this is simply Router.

While the concept of serverless applications and “event handling” brings about far more opportunties to “route” events, this interface takes the less the descriptive name purely due to familiarity.

The Router works with an ANY method on a wildcard path in API Gateway. Instead of defining paths and methods to associate with different Lambda functions, Aegis prefers a simpler approach. Though you aren’t bound to this, it certainly makes things a lot easier. However, keep in mind that your Lambda function will be triggered by API Gateway on literally any request.

Amazon’s official AWS Lambda Go package will marshal the incoming JSON event message to an API Gateway Proxy Request struct of some sort. Aegis does exactly the same thing, in fact, it aliases Amazon’s struct. The AWS Lambda Go package handles quite a number of different type of events by converting them into native Go structs. However, you can always handle a plain old map too.

Aegis adds helper functions on to several AWS Lambda Go structs. The goal is to make it feel more familiar for anyone who has ever written an API in Go before. Also take a look at the res variable in the example code. See how you can easily set res.StatusCode and return data with the res.JSON() helper function?

Note that every single handled event in Aegis has a return value, which is an error. You’ll most often simply return nil, but if you do return an actual error, it will be returned in the response in this case. Other routers may have no where to return the error of course, but a tracer might handle it (for example, AWS X-Ray will handle it).

You could also return your own error with appropriate status code with body content. For example, you could return a JSON body response that contains the error with a status code of 500. There’s even a helper function for this as well: res.JSONError(500, e) That will return the text of the error in a JSON message.

APIGatewayProxyRequest

This struct will contain a good bit of information from API Gateway. Most important of all, it will include HTTP request headers, body, and querystring parameters.

There are a bunch of helpers that you can read about in the HTTP (Proxy) Helpers section. Examples include; IP() which returns the IP address of the client. GetForm which returns form-data from the request. Cookies() which returns the cookies from the request. These are convenient functions that AWS’ core Go Lambda package does not provide, but Aegis’ aliased version does.

APIGatewayProxyResponse

This struct is responsible for the response from handled HTTP requests. Ultimately everything in AWS Lambda comes in as JSON and goes out as JSON. API Gateway will transform the response as needed, but Lambda deals with JSON for its messaging format.

Therefore, APIGatewayProxyResponse is unmarshaled by the AWS Lambda Go package but, you get to work with a nice struct that can have functions composed on to it. Again, helpers for this response include things like setting status codes, body content, headers, and more.

Like APIGatewayProxyRequest, the response struct is also an alias of the AWS Lambda Go package.

Fall Through Handler

When creating a new Router you can define a “fall through” or “catch all” handler as seen in the example code snippet. This will handle any request not matched by your router. A common handler in this case is simple one that does nothing, but sets a StatusCode of 404.

Middleware

router := aegis.NewRouter(fallThrough)
router.Handle("GET", "/", root, helloMiddleware, moreMiddleware)

router.Use(middlewareForAll, evenMoreMiddleware)

Aegis’ router also supports middleware. Standard Go http library middleware at that. You can tack on as many as you’d like to each of your router rules. You can also add middleware to every routing rule with the Use() function.

There’s some cool middleware out there for Go, Awesome Go has a middleware section and there’s also this “Secure” middleware which is pretty nice.

Tasker

The Tasker is an interface to route scheduled jobs or “tasks” that your Lambda may perform. This works in conjunction with CloudWatch Rules. So you’ll need to have events setn to your Lambda from CloudWatch.

{
    "schedule": "rate(1 minute)",
    "disabled": true,
    "input": {
        "_taskName": "test",
        "foo": "bar"
    }
}

Aegis makes this easy to do through a conventional approach. You need not configure CloudWatch events manually (though you can of course). You also don’t add them to your aegis.yaml or anything like that. It’s much easier; you’ll just add a tasks directory at the root of your source code where you run aegis deploy. Within this directory, you can include JSON files for each scheduled task you’d like to set up.

Task JSON Definition Format

The first key being schedule which takes a CloudWatch Event Rule expression (ie. “rate” or crontab format). The optional disabled key in your JSON task definition can disable your CloudWatch rule so you don’t need to delete it just to turn it off for a while.

Then you have the input key which contains an object that has your event message. This can be anything you want. It comes in to your handler as a map[string]interface{} and is “static JSON” in terms of the CloudWatch Rule configuration.

There is a conventional _taskName key in your payload that you need to be aware of. This is what gets matched by the Tasker in order to delegate to your handlers.

tasker := aegis.NewTasker(taskerFallThrough)
tasker.Handle("test", handleTestTask)

The handler should look familiar, excpet that the event is simply a map

// Example task handler
func handleTestTask(ctx context.Context, d *aegis.HandlerDependencies, evt map[string]interface{}) error {
	log.Println("Handling task!", evt)
	return nil
}

Handling Tasks

Like the API Gateway Proxy Request Router, Tasker has a “fall through” or “catch all” as well. It’s function signature is no different than any other task handler. This fall through handler is also optional. You can call aegis.NewTasker() to use just as well. In that case, if your configured Tasker does not match any task names, it will not route those events and they won’t be handled.

Obviously, there is no one to return a response message to in the case of a scheduled job. An error, or nil, must still be returned. Though the error would only be for the benefit of your tracing tool (ie. X-Ray will see it).

The event in this case will be whatever you defined in the definition JSON. Often times you may find that the most important thing is the specific handler function itself is called and not so much this static JSON payload.

RPC Router

Route and handle by name

func main() {
    rpcRouter := aegis.NewRPCRouter()
    rpcRouter.Handle("lookup", handleLookup)

    app := aegis.New(aegis.Handlers{
        // Again, your one function can handle different event types
        // Router: router,
        RPCRouter: rpcRouter,
    })
    app.Start()
}

func handleLookup(ctx context.Context, d *aegis.HandlerDependencies, evt map[string]interface{}) (map[string]interface{}, error) {
    // Some GeoIP look would happen here and some data would be returned
    return map[string]string{"city": "Somewhereville"}, nil
}

Example GeoIP lookup from another Lambda

rpcPayload := map[string]interface{}{
    "_rpcName":  "lookup",
    // or req.IP() if req is an APIGatewayProxyRequest
    "ipAddress": req.RequestContext.Identity.SourceIP,
}
resp, rpcErr := aegis.RPC("aegis_geoip", rpcPayload)

This interface allows “RPC” (remote procedure calls) to be handled. In Aegis’ world, that is to say a Lambda invoking another Lambda. So the immediate question you should have is, “how do you know it’s an invocation from another Lambda?” Great question! We don’t. Not really anyway. Plus, who’s to say it has to be another Lambda invoking it? Technically, an RPC could come from any program that has access.

The way that this router matches is, like Tasker, through conventions in the JSON message payload itself. In this case, instead of looking for a _taskName key, it’ll be _rpcName instead.

Like Tasker, RPCRouter also has an optional fall through handler that you can pass to NewRPCRouter() when setting up the router. The handler function signature is very close to the Tasker handler functions as well.

Unlike most other handlers, this one requires you to return more than just an error. A map[string]interface{} must be returned as well. What good would the RPC be without something coming back?

Technically speaking, Aegis returns data on your Router handlers as well. It’s just done for you automatically because working with API Gateway responses is predictable. The response struct is what gets returned and Aegis does that for you. However, in the case of an RPC, it’s impossible to know exactly what you are going to return other than a map, because we know that Lambda works with JSON messages.

Aegis has a top level help function, RPC(), that makes it a bit easier to invoke another Lambda. If you are looking to invoke one Lambda from another, this should just work for you without any extra effort as the default IAM role on your Lambdas through Aegis will permit Lambda invocation.

If your needs are more complex than a simple call with a function name and map payload, you will need to use the AWS SDK to invoke the Lambda instead. Or if you didn’t use Aegis to deploy your Lambdas (which would have set up an IAM role), and you have other special considerations - again, feel free to do your thing. Aegis’ helpers are not the only way to go about things.

S3 Object Router

func main() {
    s3Router := aegis.NewS3ObjectRouterForBucket("aegis-incoming")
    // s3Router.Handle("s3:ObjectCreated:Put", "*.png", handleS3Upload)
    // Put() is a shortcut for the above
    s3Router.Put("*.png", handleS3Upload)

    handlers := aegis.Handlers{
        S3ObjectRouter: s3Router,
    }
}

func handleS3Upload(ctx context.Context, d *aegis.HandlerDependencies, evt *aegis.S3Event) error {
    // evt will contain bucket name, path, etc.
    return nil
}

Now here we have an interesting Router. Like the API Gateway Proxy, we have a few things to match on. It’s not just a path, but also a method or operation. In the case of S3 objects, we’re talking about puts and deletes, other such operations, and now also a bucket (or domain if you like).

So the S3ObjectRouter has some convenient methods on it for you to use; Put(), Delete(), Copy(), and so on. Though you can choose to write it the “long” way and use Handle() where the first argument would be the actual S3 object event name.

There are two “new” methods for this router. NewS3ObjectRouter() and NewS3ObjectRouterForBucket(). Both will take an optional “fall through” handler. Both use the same router interface. NewS3ObjectRouterForBucket("bucket-name") takes a bucket name and sets it on to the router interface. NewS3ObjectRouter() does not.

This means that you can not only “catch all” operations and objects, but also all operations and object for any bucket that’s emitting events that trigger your Lambda.

Of course, you could always switch the bucket the router is working with after your have the interface. For example, assumimg you have r := NewS3ObjectRouter(), you could then do r.Bucket = "my-bucket".

Configuring S3 Object Events

bucketTriggers:
  - bucket: aegis-incoming
    filters:
      - name: suffix
        value: png
      #- name: prefix
      #  value: path/
    eventNames:
      - s3:ObjectCreated:*
      - s3:ObjectRemoved:*
      # ... there's a few and there's wildcards, see:
      # https://docs.aws.amazon.com/AmazonS3/latest/dev/NotificationHowTo.html#notification-how-to-event-types-and-destinations
    disabled: false

Unlike tasks or RPCs, we can’t simply conventionally catch events that might come our way. We actually need to ensure these events are being sent. The way that happens is through S3 object event triggers. You can set these up yourself of course (or using your favorite tool like Terraform or CloudFormation). Or, you could leverage aegis deploy by configuring your aegis.yaml file.

Aegis will look for this configuration each time you call aegis deploy from the CLI. You can also disable the triggers using the disabled key. Set that to true and deploy again. Re-enable when you’re ready again. This way you don’t need to lose anything in your configuration. Aegis is also non-destructive.

Event Matching

What goes on within Aegis to match an S3 object event

if r.Bucket == "" || r.Bucket == record.S3.Bucket.Name {
    // Handlers are registered in a map.
    // The keys in this map contain your match pattern string.
    for globStr, handler := range r.handlers {
    g = glob.MustCompile(globStr)
    if g.Match(record.S3.Object.Key) {
        // ...Goes on to handle the event, calling your handler.
    }
}

Unlike most other routers, S3 object events are matched by glob match. Again, if no match is found, it will use a fall through handler if you provided one. Note that the bucket name is optional. You could handle objects sent to any bucket provided its events trigger your Lambda.

So for example, *.png will handle any object that ends with a png extension. Whereas {*.png,*.gif,*.jpg} would handle any file with those extensions. The package used for glob matching is gobwas/glob. You can read its documentation for all the options you have available to match, it’s rather robust.

A good thing to think about here is the order in which you define your routing rules. It won’t really be a concern for you with other routers, but this one can result in multiple rules matching the same event depending on what you have set up. The first route match found will be used, calling its handler.

Remember, there are 3 things at play here:

SES Router

func main() {
    sesReceiver := aegis.NewSESRouter()
    sesReceiver.Handle("*@ses.serifandsemaphore.io", handleEmail)

    handlers := aegis.Handlers{
        SESRouter: sesReceiver,
    }
    AegisApp = aegis.New(handlers)
    AegisApp.Start()
}

func handleEmail(ctx context.Context, d *aegis.HandlerDependencies, evt *aegis.SimpleEmailEvent) error {
    log.Println(evt)
    return nil
}

The AWS SES (Simple E-mail Service) router, SESRouter, and the ability for a Lambda to receive e-mail is perhaps one of the coolest things ever. Yes, there is such a thing as serverless e-mail.

You can route based on any address match, or address matches within a specific domain, using NewSESRouter() and NewSESRouterForDomain() respectively.

Like the S3ObjectRouter, the SES router will use glob based matching. So, first the domain is check if set, and then your pattern is checked against the e-mail address.

How to Read E-mails

Example SES config

sesRules:
  - ruleName: aegis-test
    enabled: true
    requireTLS: false
    scanEnabled: true
    # ruleSet: ... optional string, defaults to aegis-default-rule-set
    # invocationType: Event # default to Event, RequestResponse is other option less common and has to return in 30 seconds
    # snsTopicArn: ... optional SNS topic to also notify - SNS setup is outside aegis deploy for now
    recipients:
      - example@ses.serifandsemaphore.io
    # Also, an S3 bucketTrigger could be used to pick up the e-mail in its entirety OR the an SES event can be
    # used because it will provide a message ID that can be looked for in S3.
    s3Bucket: "aegis-incoming"
    # subdirectory
    s3ObjectKeyPrefix: "ses_"
    s3encryptMessage: false
    # s3KMSKeyArn: ... s3encryptMessage will use the default KMS key for encrypting email if true unless this is provided
    # s3SNSTopicArn: "sometopic" ... an optional SNS topic to publish when messages are saved to S3, different from the SNS topic about the event

There’s something important to note about what you get in the event message from an SES event. You are not getting the full body of the e-mail. Just the e-mail “headers” come through. Things like the from address, to address, CC, BCC, and subject line.

If you want to actually read the body of the e-mail, you’ll need to set up a few things in AWS first. What happens is the e-mail gets stored, in full, into S3 (if it’s set up to do so). You then read it from S3. E-mails can be stored in S3 encrypted too if you configure that as well.

To help with all this configuration in AWS, Aegis deploy command will read settings from aegis.yaml to set things up.

Also keep in mind that you can use an S3ObjectRouter to handle incoming objects into whatever bucket is receiving your e-mails too. The difference is that you wouldn’t then get the e-mail headers and you’d have to read the file to know what the e-mail was about. The SES handler would let your code make a determination as to whether or not it wanted to retrieve the file from S3.

SQS Router

func main() {
    sqsRouter := aegis.NewSQSRouterForBucket("aegis-queue")
    sqsRouter.Handle("attr", "value", handleSQSMessage)

    handlers := aegis.Handlers{
        SQSRouter: sqsRouter,
    }
}

func handleSQSMessage(ctx context.Context, d *aegis.HandlerDependencies, evt *aegis.SQSEvent) error {
    // evt will contain the message body, attributes, etc.
    return nil
}

Each SQS queue allows one Lambda to be invoked when new messages enter the queue. However, multiple queues may go to trigger the same Lambda function. This router plays an important role here.

Further, SQS messages can vary wildly. You will end up defining your message conventions. This is especially true when it comes to each message’s attributes. This is where the router’s rules are applied too.

When you handle an SQS event you can match on a message attribute name and it’s string value. SQS will always provide a string representation of the message attribute value. If it is binary, this means a base64 string. Therefore Handle() always takes two strings for the first two arguments; key and value.

If you wish to handle all messages for a given queue, where you have the most flexibility, you can simply use an SQS router’s root/falltrhough handler. Like the S3Object Router, there is a more terse NewSQSRouterForQueue() function to create a router for a specific queue in one line.

Note: At this time Aegis CLI will not create or manage SQS queues for you. It will associate the necessary execution role for your Lambda, but you will need to manage your own queues and which Lambda functions they trigger.

Cognito Router

There are some AWS Cognito specific events that can be routed as well. These events are for User Pool workflows. You can find more information about the Cognito triggers here.

The Cognito router is matching on the triggerSource value here. You also can have separate handlers for different User Pools. You’ll just need to set a PoolID on the router or use NewCognitoRouterForPool() instead of NewCognitoRouter().

Perhaps the most interesting or useful thing about this router is the ability to have your Lambda easily add functionality to AWS Cognito. It’s a great place to stick handlers to send out e-mails and more.