iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🐏

Building commands that run on both Lambda and CLI with lamblocal - fujiwara-ware 2024 day 13

に公開

This article is the 13th day of fujiwara-ware advent calendar 2024.

What is lamblocal?

https://github.com/fujiwara/lamblocal

lamblocal is a library for creating commands that can run both as AWS Lambda functions and as CLI tools. You might wonder, "What does that mean?", but there are benefits to being able to do this.

Lambda functions receive a JSON payload (input) and return a JSON response (output). Execution typically happens within the Lambda runtime. However, for example, what if you want to run this function locally during development?

Officially, AWS SAM CLI is provided, which allows you to run Lambda functions locally. However, since it involves things like Docker, the setup feels a bit heavy-handed...

https://github.com/aws/aws-sam-cli

Basically, a Lambda function is a "command that receives JSON input and returns JSON output." To run this as a CLI, you just need to implement it as a "command that receives JSON from standard input and outputs JSON to standard output." There's no need to resort to Docker.

lamblocal is a library to make such Lambda functions runnable as a CLI as well. It is written for the Go language.

Usage

When implementing a Lambda function in Go, you write code like this. You pass the function you want to execute (in this case, hello) to lambda.Start.

package main

import (
    "github.com/aws/aws-lambda-go/lambda"
)

func hello(_ context.Context, name string) (string, error) {
    return fmt.Sprintf("Hello %s", name), nil
}

func main() {
    lambda.StartWithContext(context.TODO(), hello)
}

When you provide this Lambda function with the input "world" (a JSON string), it returns the output "Hello world". To make this work as a CLI using lamblocal, you rewrite it as follows:

package main

import (
    "github.com/fujiwara/lamblocal"
)

func hello(_ context.Context, name string) (string, error) {
    return fmt.Sprintf("Hello %s", name), nil
}

func main() {
    lamblocal.Run(context.TODO(), hello)
}

The implementation of the hello function is exactly the same. You just change lambda.StartWithContext to lamblocal.Run.

With this, the code works as a Lambda function when executed on Lambda, and as a CLI when executed in other environments.

$ echo '"world"' | go run main.go 
"Hello world"

Handling configuration values in Lambda

By the way, configuration values for Lambda are passed via environment variables. Unlike the CLI, you cannot use command-line arguments.

When reading those configuration values in an application, for example in Go, you would use os.Getenv to read environment variables. However, if you write this naively, the code quickly becomes difficult to maintain as it grows larger.

If you call Getenv right where the value is needed, and the configuration value is empty or unintended, an error will only occur at runtime at that specific spot. If such a call is embedded in a process that is rarely executed, the error might only surface long after deployment. This makes operations very difficult, so we want to discover configuration flaws during initialization.

Solution using a CLI flag parser that handles environment variables

As an application example, I will introduce how to combine it with a command-line parser called alecthomas/kong.

https://github.com/alecthomas/kong

In kong, you define command-line arguments as a struct and use struct tags to define default values and which environment variables to read values from.

type CLI struct {
    Foo string `help:"This is Foo." default:"foo" env:"FOO"`
}

With this definition, the value of CLI.Foo will be the default foo. If the environment variable FOO=bar is set, it becomes bar. If the command-line argument --foo=baz is given, it becomes baz. In other words, after setting an appropriate default value, you can overwrite it from environment variables when running on Lambda, or from arguments when running as a CLI. You can also explicitly describe what the value is in the help text.

If a string that cannot be converted to the element's type is passed, an error occurs. It also includes features such as required values, accepting only specific values (enum), and accepting multiple elements separated by a delimiter, making configuration validation easy.

In short, as shown below, you can handle configuration values safely without writing code that randomly reads environment variables within the Lambda handler.

type CLI struct {
    Foo string `help:"Foo." default:"foo" env:"FOO"`
}

func (c *CLI) Handler(ctx context.Context, _ interface{}) (string, error) {
    // c.Foo is in a state where it has been set from the default value, environment variables, or command-line arguments
    return c.Foo, nil
}

func main() {
    var c CLI
    kong.Parse(&c)
    lamblocal.Run(context.TODO(), c.Handler)
}

Summary

lamblocal is a library for enabling Lambda functions to be executed as CLI tools as well. By making code implemented as a Lambda function runnable as a CLI, you can improve efficiency during development.

In addition, as an application example, by handling configuration values through a CLI flag parser instead of directly dealing with environment variables, you can simplify configuration validation and error detection during initialization.

When writing Lambda functions in Go, please give lamblocal a try.

That's all, and stay tuned for tomorrow!

References

https://sfujiwara.hatenablog.com/entry/lamblocal

Discussion