Defining your API

Here is how you might define the classic Petstore API example:

// build.sbt

lazy val petstore = project
  .enablePlugins(ScalambdaPlugin)
  .settings({
    // save the lambda function to a value so you can re-use it across multiple endpoints 
    lazy val petsHandler = Function(
      functionClasspath = ??? // example: "io.carpe.example.CreatePet"
    )
    
    // the apiGatewayDefinition function allows us name our api and map the lambda function above to http endpoints
    apiGatewayDefinition(apiGatewayInstanceName = "petstore-api-${terraform.workspace}")(
      // This sends all POST requests to "<my api domain>/pets" to our lambda function
      POST("/pets") -> petsHandler,
      // This sends all GET requests to "<my api domain>/pets" to our lambda function
      GET("/pets") -> petsHandler,
      // This sends all GET requests to "<my api domain>/pets/<some pet id>" to our lambda function
      // (it also makes "id" available as a path parameter, inside the pathParameters field on the request)
      GET("/pets/{id}") -> petsHandler
    )
  })

As you can see in the above example, we can map the same function to multiple endpoints. This helps keep cold start times down by allowing your functions to be re-used more frequently. You can just as easily define a lambda function for each endpoint if you’d prefer.

Handling Authorization

There are two ways to secure your Api Gateways. The high-level steps for adding both to your API are the same:

  1. Define the desired Auth in SBT
  2. Run scalambdaTerraform to generate terraform (do not deploy anything just yet)
  3. Run terraform validate to make sure you’ve met all the requirements for your chosen Auth

Auth via Api-Key

Easy, but not flexible/secure enough for most use-cases

Auth.ApiKey will require your users to provide an Api Key when making requests. By default, this Api Key is passed in via the X-Api-Key header.

// build.sbt

lazy val petstore = project
  .enablePlugins(ScalambdaPlugin)
  .settings({
    val petsHandler = ???
    val loginHandler = ???

    // this auth config will be implicitly applied to the POST and GET methods below.
    // Auth.ApiKey will set the endpoint to require an Api Key via Api Gateway's Api Key service (be warned that using
    // only Api Key authorization is not recommended by AWS).
    implicit val apiGatewayApiKeyAuthorizer: Auth = Auth.ApiKey
    
    apiGatewayDefinition(apiGatewayInstanceName = "petstore-api-${terraform.workspace}")(
      POST("/pets") -> petsHandler,
      GET("/pets") -> petsHandler
      // allow all users to hit the login endpoint by explicitly passing `Auth.AllowAll`
      POST("/login")(Auth.AllowAll) -> loginHandler
    )
  })

Auth via Lambda Authorizer

Difficult, but will work for nearly any use-case

If you need something more flexible than Api Keys OR you want to inject and cache data within each user’s session, you probably want to use Custom Lambda Authorizers. Before you jump in straight in though, you’ll probably want to read up on Custom Authorizers in the AWS Docs.

Finished reading the documentation above? Good. So, according to AWS, there are two different authorizer types (REQUEST or TOKEN). The majority of APIs will use Authorizers of type TOKEN, but here’s how to define both of them in SBT:

// build.sbt

// Lambda Authorizer of type TOKEN
implicit val myTokenAuthorizer: Auth = Auth.TokenAuthorizer(
  // this name can be anything you'd like. It will be ysed to create variables in the terraform module that is outputted by
  // the `scalambdaTerraform` task.
  tfVariableName = "my_token_authorizer"
)

// Lambda Authorizer of type REQUEST
implicit val myRequestAuthorizer: Auth = Auth.RequestAuthorizer(
  // this name can be anything you'd like. It will be used to create variables in the terraform module that is outputted by
  // the `scalambdaTerraform` task.
  tfVariableName = "my_request_authorizer", 
  identitySources = Seq("method.request.header.X-Api-Key")
)

Once you’ve defined these authorizers in SBT, you’ll likely want to run scalambdaTerraform to generate terraform variables that you can use to connect your Api to the Authorizers.

module "my_api" {
  // path to terraform generated by `scalambdaTerraform` (your actual path may differ from this one)
  source = "./target/terraform" 

  // standard input variables for the pets handler lambda we created in the last example
  pets_handler_lambda_role_arn = "<arn:aws:iam:0123456789:role/for-the-pets-handler-function>"

  // These next two variables are generated if you defined either an Auth.TokenAuthorizer or Auth.RequestAuthorizer
  authorizer_role = "<arn:aws:iam:0123456789:role/for-the-authorizer>" // role for invoking the authorizer lambda
  authorizer_uri = "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/YourCustomAuthorizerFunction/invocations" // invocation endpoint for the authorizer lambda function  
}

Auth via Both

Want to take advantage of AWS Usage Plans and Api Keys, but also need to inject in some custom session data? You can use Auth.Multiple to combine as many different authorization methods as you’d like.

// this type of Auth config combines multiple different authorization methods. It will require both of the authorization
// methods to "pass" before allowing the Lambda function to be invoked.
implicit val requireApiKeyAndRequestAuthorizer: Auth = Auth.Multiple(
  // request authorizer that fetches data from the X-Api-Key header (the same one that the ApiKey looks at)
  Auth.RequestAuthorizer("authorizer", identitySources = Seq("method.request.header.X-Api-Key")),
  Auth.ApiKey
)

Handling CORS

Scalambda automatically adds OPTIONS request handling to each endpoint in your API. If you prefer to handle CORS yourself, you have a few options.

Disable Scalambda’s automatic handling of CORS

Here is an example of how you can disable OPTIONS request handling for the /pets route:

lazy val petstore = project
  .enablePlugins(ScalambdaPlugin)
  .settings({
    val petsHandler = ???
    
    apiGatewayDefinition(apiGatewayInstanceName = "petstore-api-${terraform.workspace}")(
      // setting to CORS.AllowNone will prevent scalambda from adding a default OPTIONS request handler for "/pets"
      POST("/pets", cors = CORS.AllowNone) -> petsHandler,
      GET("/pets", cors = CORS.AllowNone) -> petsHandler
    )
  })

Create a Lambda to handle OPTIONS requests

Here is an example of how you use your own Lambda to handle OPTIONS requests for the /pets route:

lazy val petstore = project
  .enablePlugins(ScalambdaPlugin)
  .settings({
    val petsHandler = ???
    val optionsHandler = ???
    
    apiGatewayDefinition(apiGatewayInstanceName = "petstore-api-${terraform.workspace}")(
      POST("/pets") -> petsHandler,
      GET("/pets") -> petsHandler
      // this OPTIONS request handler will override the one scalambda provides by default for "/pets" 
      OPTIONS("/pets")(Auth.AllowAll) -> optionsHandler
    )
  })

Defining Lambdas for ApiGateway

When Api Gateway receives a request, it will invoke the configured Lambda Function with what AWS calls an “Api Gateway Proxy Request”. They also expect your function to provide a response in the form of an “Api Gateway Proxy Response”.

Scalambda provides both of these as traits that you can use in your Lambda Functions like so:

package io.carpe.example

import com.amazonaws.services.lambda.runtime.Context
import io.carpe.scalambda.Scalambda
import io.carpe.scalambda.request.APIGatewayProxyRequest
import io.carpe.scalambda.response.{APIGatewayProxyResponse, ApiError}


class Greeter extends Scalambda[APIGatewayProxyRequest[String], APIGatewayProxyResponse[String]] {

  /**
   * Accept a request that provides someone's name in a JSON body.
   *
   * Response with a greeting for that given person.
   *
   * @param input from api gateway that represents the request
   * @param context lambda request context
   * @return
   */
  override def handleRequest(input: APIGatewayProxyRequest[String], context: Context): APIGatewayProxyResponse[String] = {
    val greetingResponse = for {
      // attempt to get the provided name from the input
      inputName <- input.body

      // use it to create a greeting
      greeting = s"Hello, ${inputName}!"
    } yield {
      // place the greeting inside a response object, along with any headers that you'd like
      // to supply. 
      APIGatewayProxyResponse.WithBody(
        statusCode = 200,
        headers = Map(
          "content-type" -> "application/json"
        ),
        body = greeting
      )
    }

    // return the result, or an error to Api Gateway
    greetingResponse.getOrElse({
      APIGatewayProxyResponse.WithError(
        // ApiError has a default encoder that will be used to inject errors into the 
        // response body as json. You can override this encoder if you'd like, it is an implicit
        // parameter for the APIGatewayProxyResponse.WithError's constructor 
        error = ApiError.InputError("No input was provided"),      
        headers = Map(
          "content-type" -> "application/json"
        )
      )
    })
  }
}

As you can see, there really isn’t too much of a difference between a Lambda Function that serves requests from Api Gateway and one that does not. The only thing that changes is the input to your Function.