Spring Cloud Function
Overview
Section titled “Overview”In this guide, you will learn how to use LocalStack to test your serverless applications powered by Spring Cloud Function framework.
Covered Topics
Section titled “Covered Topics”We will create a new Rest API application that will route requests
to a Cloud Function using functionRouter
and routing expressions.
The primary language for the application is Kotlin powered by Gradle build tool, but the described concepts would work for any other JVM setup.
- Overview
- Covered Topics
- Setting up an Application
- Starting a new Project
- Project Settings
- Configure Log4J2 for AWS Lambda
- Configure Spring Cloud Function for Rest API
- Define an Application class
- Configure Jackson
- Define Logging Utility
- Add Request/Response utilities
- Creating a sample Model / DTO
- Creating Rest API endpoints
- Creating other lambda Handlers
- Setting up Deployment
- Testing, Debugging and Hot Reloading
- Useful Links
Current Limitations
Section titled “Current Limitations”This document demonstrates the usage of the Spring Cloud Function framework together with LocalStack. It does not cover some of the application-specific topics, like 404 error handling, or parametrized routing, that you need to consider when building production-ready applications.
Setting up an Application
Section titled “Setting up an Application”We recommend using jenv to manage multiple Java runtimes.
Starting a new Project
Section titled “Starting a new Project”Please follow the instructions from the official website to install the Gradle build tool on your machine.
Then run the following command to initialize a new Gradle project
gradle init
Running the command below will run the gradle wrapper task
gradle wrapper
After running the wrapper task, you will find the Gradle wrapper script gradlew
.
From now on, we will use the wrapper instead of the globally installed Gradle binary:
./gradlew <command>
Project Settings
Section titled “Project Settings”Let’s give our project a name:
open settings.gradle
, and adjust the autogenerated name to something meaningful.
rootProject.name = 'localstack-sampleproject'
Now we need to define our dependencies. Here’s a list of what we will be using in our project.
Gradle plugins:
- java
- kotlin jvm
- kotlin spring plugin
- spring boot plugin
- spring dependency management plugin
- shadow plugin
Dependencies:
- kotlin stdlib
- spring cloud starter function web
- spring cloud function adapter for aws
- lambda log4j2
- lambda events
- jackson core
- jackson databind
- jackson annotations
- jackson module kotlin
In order to deploy our application to AWS, we need to build so-called “fat jar” which contains all application dependencies. To that end, we use the “Shadow Jar” plugin.
Here’s our final build.gradle
:
plugins { id "java" id "org.jetbrains.kotlin.jvm" version '1.5.31' id "org.jetbrains.kotlin.plugin.spring" version '1.5.31' id 'org.springframework.boot' version '2.5.5' id "io.spring.dependency-management" version '1.0.11.RELEASE' id "com.github.johnrengelman.shadow" version '7.0.0'}
group = 'org.localstack.sampleproject'sourceCompatibility = 11
tasks.withType(JavaCompile) { options.encoding = 'UTF-8'}
repositories { mavenCentral() maven { url "https://plugins.gradle.org/m2/" }}
ext { springCloudVersion = "3.1.4" awsLambdaLog4jVersion = "1.2.0" awsLambdaJavaEventsVersion = "3.10.0" jacksonVersion = "2.12.5"}
dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib"
implementation "org.springframework.cloud:spring-cloud-starter-function-web:$springCloudVersion" implementation "org.springframework.cloud:spring-cloud-function-adapter-aws:$springCloudVersion"
implementation "com.amazonaws:aws-lambda-java-log4j2:$awsLambdaLog4jVersion" implementation "com.amazonaws:aws-lambda-java-events:$awsLambdaJavaEventsVersion"
implementation "com.fasterxml.jackson.core:jackson-core:$jacksonVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" implementation "com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion" implementation "com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion"}
import com.github.jengelman.gradle.plugins.shadow.transformers.*
// Configure the main classjar { manifest { attributes 'Start-Class': 'org.localstack.sampleproject.Application' }}
// Build a fatjar (with dependencies) for aws lambdashadowJar { transform(Log4j2PluginsCacheFileTransformer)
dependencies { exclude( dependency("org.springframework.cloud:spring-cloud-function-web:${springCloudVersion}") ) }
// Required for Spring mergeServiceFiles()
append 'META-INF/spring.handlers' append 'META-INF/spring.schemas' append 'META-INF/spring.tooling'
transform(PropertiesFileTransformer) { paths = ['META-INF/spring.factories'] mergeStrategy = "append" }}
assemble.dependsOn shadowJar
Please note that we will be using org.localstack.sampleproject
as a
working namespace, and org.localstack.sampleproject.Application
as an
entry class for our application.
You can adjust it for your needs, but don’t forget to change your package names accordingly.
Configure Log4J2 for AWS Lambda
Section titled “Configure Log4J2 for AWS Lambda”Spring framework comes with Log4J logger, so all we need to do is to configure
it for AWS Lambda.
In this project, we are following
official documentation
to setup up src/main/resources/log4j2.xml
content.
?xml version="1.0" encoding="UTF-8"?><Configuration packages="com.amazonaws.services.lambda.runtime.log4j2.LambdaAppender"> <Appenders><Lambda name="Lambda"> <PatternLayout> <pattern>%d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1}:%L - %m%n</pattern> </PatternLayout> </Lambda> </Appenders> <Loggers><Root level="debug"><AppenderRef ref="Lambda" /> </Root> </Loggers></Configuration>
Configure Spring Cloud Function for Rest API
Section titled “Configure Spring Cloud Function for Rest API”Spring Function comes with functionRouter
that can route
requests to different Beans
based on predefined routing expressions.
Let’s configure it to lookup our function Beans by HTTP method and path, create a
new application.properties
file under src/main/resources/application.properties
with the following content:
spring.main.banner-mode=offspring.cloud.function.definition=functionRouterspring.cloud.function.routing-expression=headers['httpMethod'].concat(' ').concat(headers['path'])spring.cloud.function.scan.packages=org.localstack.sampleproject.api
Once configured, you can use FunctionInvoker
as a handler for your Rest API lambda function.
It will automatically pick up the configuration we have just set.
org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest
Define an Application class
Section titled “Define an Application class”Now our application needs an entry-class, the one we referenced earlier.
Let’s add it under src/main/kotlin/org/localstack/sampleproject/Application.kt
.
package org.localstack.sampleproject
import org.springframework.boot.autoconfigure.SpringBootApplication
@SpringBootApplicationclass Application
fun main(args: Array<String>) { // Do nothing unless you use a custom runtime}
Configure Jackson
Section titled “Configure Jackson”In our sample project we are using a JSON format for requests and responses.
The easiest way to get started with JSON is to use the Jackson library.
Let’s configure it by creating a new configuration class JacksonConfiguration.kt
under
src/main/kotlin/org/localstack/sampleproject/config
:
package org.localstack.sampleproject.config
import com.fasterxml.jackson.annotation.JsonIncludeimport com.fasterxml.jackson.databind.*import org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.context.annotation.Primaryimport org.springframework.http.converter.json.Jackson2ObjectMapperBuilderimport java.text.DateFormat
@Configurationclass JacksonConfiguration {
@Bean fun jacksonBuilder() = Jackson2ObjectMapperBuilder() .dateFormat(DateFormat.getDateInstance(DateFormat.FULL))
@Bean @Primary fun objectMapper(): ObjectMapper = ObjectMapper().apply { configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true) configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) setSerializationInclusion(JsonInclude.Include.NON_NULL) findAndRegisterModules() }}
In applications where you need support for multiple formats or a format different from JSON (for example, SOAP/XML applications) simply use multiple beans with corresponding ObjectMapper implementations.
Define Logging Utility
Section titled “Define Logging Utility”Let’s create a small logging utility to simplify interactions with the logger
package org.localstack.sampleproject.util
import org.apache.logging.log4j.LogManagerimport org.apache.logging.log4j.Logger
open class Logger { val LOGGER: Logger = LogManager.getLogger(javaClass.enclosingClass)}
Add Request/Response utilities
Section titled “Add Request/Response utilities”To reduce the amount of boilerplate code, we are going to introduce three utility functions for our Rest API communications:
- to build regular json response
- to build error json response
- to parse request payload using ObjectMapper. Note that ObjectMapper does not necessarily need to be a JSON only. It could also be XML or any other Mapper extended from standard ObjectMapper. Your application may even support multiple protocols with different request/response formats at once.
Let’s define utility functions to to build API gateway responses:
package org.localstack.sampleproject.util
import org.springframework.messaging.Messageimport org.springframework.messaging.support.MessageBuilder
data class ResponseError( val message: String,)
fun <T>buildJsonResponse(data: T, code: Int = 200): Message<T> { return MessageBuilder .withPayload(data) .setHeader("Content-Type", "application/json") .setHeader("Access-Control-Allow-Origin", "*") .setHeader("Access-Control-Allow-Methods", "OPTIONS,POST,GET") .setHeader("statusCode", code) .build()}
fun buildJsonErrorResponse(message: String, code: Int = 500) = buildJsonResponse(ResponseError(message), code)
And now a utility function to process API Gateway requests:
package org.localstack.sampleproject.util
import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEventimport com.fasterxml.jackson.databind.ObjectMapperimport org.springframework.messaging.Messageimport java.util.function.Function
fun <T>apiGatewayFunction( objectMapper: ObjectMapper, callable: (message: Message<T>, context: APIGatewayProxyRequestEvent) -> Message<*>): Function<Message<T>, Message<*>> = Function { input -> try { val context = objectMapper.readValue( objectMapper.writeValueAsString(input.headers), APIGatewayProxyRequestEvent::class.java )
return@Function callable(input, context) } catch (e: Throwable) { val message = e.message?.replace("\n", "")?.replace("\"", "'") return@Function buildJsonErrorResponse(message ?: "", 500) }}
Creating a sample Model / DTO
Section titled “Creating a sample Model / DTO”To transfer data from requests into something more meaningful than JSON strings (and back) you will be using a lot of Models and Data Transfer Objects (DTOs). It’s time to define our first one.
package org.localstack.sampleproject.model
import com.fasterxml.jackson.annotation.JsonIgnore
data class SampleModel( val id: Int, val name: String,
@JsonIgnore val jsonIgnoredProperty: String? = null,)
Creating Rest API endpoints
Section titled “Creating Rest API endpoints”Let’s add our first endpoints to simulate CRUD operations on previously
defined SampleModel
:
package org.localstack.sampleproject.api
import com.fasterxml.jackson.databind.ObjectMapperimport org.localstack.sampleproject.model.SampleModelimport org.localstack.sampleproject.util.Loggerimport org.localstack.sampleproject.util.apiGatewayFunctionimport org.localstack.sampleproject.util.buildJsonResponseimport org.springframework.context.annotation.Beanimport org.springframework.stereotype.Component
private val SAMPLE_RESPONSE = mutableListOf( SampleModel(id = 1, name = "Sample #1"), SampleModel(id = 2, name = "Sample #2"),)
@Componentclass SampleApi(private val objectMapper: ObjectMapper) {
companion object : Logger()
@Bean("POST /v1/entities") fun createSampleEntity() = apiGatewayFunction<SampleModel>(objectMapper) { input, context -> LOGGER.info("calling POST /v1/entities") SAMPLE_RESPONSE.add(input.payload) buildJsonResponse(input.payload, code = 201) }
@Bean("GET /v1/entities") fun listSampleEntities() = apiGatewayFunction<ByteArray>(objectMapper) { input, context -> LOGGER.info("calling GET /v1/entities") buildJsonResponse("hello world") }
@Bean("GET /v1/entities/get") fun getSampleEntity() = apiGatewayFunction<ByteArray>(objectMapper) { input, context -> LOGGER.info("calling GET /v1/entities/get") val desiredId = context.queryStringParameters["id"]!!.toInt() buildJsonResponse(SAMPLE_RESPONSE.find { it.id == desiredId }) }}
Note how we used Spring’s dependency injection to inject ObjectMapper
Bean we
configured earlier.
Cold Start and Warmup Pro
Section titled “Cold Start and Warmup ”We know Java’s cold start is always a pain. To minimize this pain, we will try to define a pre-warming endpoint within the Rest API. By invoking this function every 5-10 mins we can make sure Rest API lambda is always kept in a pre-warmed state.
package org.localstack.sampleproject.api
import com.fasterxml.jackson.databind.ObjectMapperimport org.localstack.sampleproject.util.apiGatewayFunctionimport org.localstack.sampleproject.util.buildJsonResponseimport org.springframework.context.annotation.Beanimport org.springframework.stereotype.Component
@Componentclass ScheduleApi(private val objectMapper: ObjectMapper) {
@Bean("SCHEDULE warmup") fun warmup() = apiGatewayFunction<ByteArray>(objectMapper) { input, context -> // execute scheduled events buildJsonResponse("OK") }}
Now you can add a scheduled event to the Rest API lambda function with the following synthetic payload (to simulate API gateway request). This way, you can define any other scheduled events, but we recommend using pure lambda functions.
{ "httpMethod": "SCHEDULE", "path": "warmup"}
As you may have guessed, this input will get mapped to the SCHEDULE warmup
Bean.
For more information, please read the “Setting up Deployment” section.
Creating other lambda Handlers
Section titled “Creating other lambda Handlers”HTTP requests are not the only thing our Spring Function-powered lambdas can do. We can still define pure lambda functions, DynamoDB stream handlers, and so on.
Below you can find a little example of few lambda functions grouped in LambdaApi
class.
package org.localstack.sampleproject.api
import com.amazonaws.services.lambda.runtime.events.DynamodbEventimport org.localstack.sampleproject.model.SampleModelimport org.localstack.sampleproject.util.Loggerimport org.springframework.cloud.function.adapter.aws.SpringBootStreamHandlerimport org.springframework.context.annotation.Beanimport org.springframework.stereotype.Componentimport java.util.function.Function
@Componentclass LambdaApi : SpringBootStreamHandler() {
companion object : Logger()
@Bean fun functionOne(): Function<Any, String> { return Function { LOGGER.info("calling function one") return@Function "ONE"; } }
@Bean fun functionTwo(): Function<SampleModel, SampleModel> { return Function { LOGGER.info("calling function two") return@Function it; } }
@Bean fun dynamoDbStreamHandlerExample(): Function<DynamodbEvent, Unit> { return Function { LOGGER.info("handling DynamoDB stream event") } }}
As you can see from the example above, we are using SpringBootStreamHandler
class as a base that takes care of the application bootstrapping process and
AWS requests transformation.
Now org.localstack.sampleproject.api.LambdaApi
can be used as a handler for
your lambda function along with FUNCTION_NAME
environmental variable with
the function bean name.
You may have noticed we used DynamodbEvent
in the last example.
The Lambda-Events
package comes with a set of predefined wrappers that you can use to handle different lifecycle events from AWS.
Setting up Deployment
Section titled “Setting up Deployment”Check our sample project for usage examples.
service: localstack-sampleproject-serverless
provider: name: aws runtime: java11 stage: ${opt:stage} region: us-west-1 lambdaHashingVersion: 20201221 deploymentBucket: name: deployment-bucket
package: artifact: build/libs/localstack-sampleproject-all.jar
plugins:* serverless-localstack* serverless-deployment-bucket
custom: localstack: stages: - local
functions: http_proxy: timeout: 30 handler: org.springframework.cloud.function.adapter.aws.FunctionInvoker::handleRequest events: - http: path: /{proxy+} method: ANY cors: true # Please, note that events are a LocalStack PRO feature - schedule: rate: rate(10 minutes) enabled: true input: httpMethod: SCHEDULE path: warmup lambda_helloOne: timeout: 30 handler: org.localstack.sampleproject.api.LambdaApi environment: FUNCTION_NAME: functionOne lambda_helloTwo: timeout: 30 handler: org.localstack.sampleproject.api.LambdaApi environment: FUNCTION_NAME: functionTwo
package org.localstack.cdkstack
import java.util.UUIDimport software.amazon.awscdk.core.Constructimport software.amazon.awscdk.core.Durationimport software.amazon.awscdk.core.Stackimport software.amazon.awscdk.services.apigateway.CorsOptionsimport software.amazon.awscdk.services.apigateway.LambdaRestApiimport software.amazon.awscdk.services.apigateway.StageOptionsimport software.amazon.awscdk.services.events.Ruleimport software.amazon.awscdk.services.events.RuleTargetInputimport software.amazon.awscdk.services.events.Scheduleimport software.amazon.awscdk.services.events.targets.LambdaFunctionimport software.amazon.awscdk.services.lambda.*import software.amazon.awscdk.services.lambda.Functionimport software.amazon.awscdk.services.s3.Bucket
private val STAGE = System.getenv("STAGE") ?: "local"private const val JAR_PATH = "../../build/libs/localstack-sampleproject-all.jar"
class ApplicationStack(parent: Construct, name: String) : Stack(parent, name) {
init { val restApiLambda = Function.Builder.create(this, "RestApiFunction") .code(Code.fromAsset(JAR_PATH)) .handler("org.springframework.cloud.function.adapter.aws.FunctionInvoker") .timeout(Duration.seconds(30)) .runtime(Runtime.JAVA_11) .tracing(Tracing.ACTIVE) .build()
val corsOptions = CorsOptions.builder().allowOrigins(listOf("*")).allowMethods(listOf("*")).build()
LambdaRestApi.Builder.create(this, "ExampleRestApi") .proxy(true) .restApiName("ExampleRestApi") .defaultCorsPreflightOptions(corsOptions) .deployOptions(StageOptions.Builder().stageName(STAGE).build()) .handler(restApiLambda) .build()
val warmupRule = Rule.Builder.create(this, "WarmupRule") .schedule(Schedule.rate(Duration.minutes(10))) .build()
val warmupTarget = LambdaFunction.Builder.create(restApiLambda) .event(RuleTargetInput.fromObject(mapOf("httpMethod" to "SCHEDULE", "path" to "warmup"))) .build()
// Please note that events is a LocalStack PRO feature warmupRule.addTarget(warmupTarget)
SingletonFunction.Builder.create(this, "ExampleFunctionOne") .code(Code.fromAsset(JAR_PATH)) .handler("org.localstack.sampleproject.api.LambdaApi") .environment(mapOf("FUNCTION_NAME" to "functionOne")) .timeout(Duration.seconds(30)) .runtime(Runtime.JAVA_11) .uuid(UUID.randomUUID().toString()) .build()
SingletonFunction.Builder.create(this, "ExampleFunctionTwo") .code(Code.fromAsset(JAR_PATH)) .handler("org.localstack.sampleproject.api.LambdaApi") .environment(mapOf("FUNCTION_NAME" to "functionTwo")) .timeout(Duration.seconds(30)) .runtime(Runtime.JAVA_11) .uuid(UUID.randomUUID().toString()) .build() }}
variable "STAGE" { type = string default = "local"}
variable "AWS_REGION" { type = string default = "us-east-1"}
variable "JAR_PATH" { type = string default = "build/libs/localstack-sampleproject-all.jar"}
provider "aws" { access_key = "test_access_key" secret_key = "test_secret_key" region = var.AWS_REGION s3_force_path_style = true skip_credentials_validation = true skip_metadata_api_check = true skip_requesting_account_id = true
endpoints { apigateway = var.STAGE == "local" ? "http://localhost:4566" : null cloudformation = var.STAGE == "local" ? "http://localhost:4566" : null cloudwatch = var.STAGE == "local" ? "http://localhost:4566" : null cloudwatchevents = var.STAGE == "local" ? "http://localhost:4566" : null iam = var.STAGE == "local" ? "http://localhost:4566" : null lambda = var.STAGE == "local" ? "http://localhost:4566" : null s3 = var.STAGE == "local" ? "http://localhost:4566" : null }}
resource "aws_iam_role" "lambda-execution-role" { name = "lambda-execution-role"
assume_role_policy = <<EOF{ "Version": "2012-10-17", "Statement": [ { "Action": "sts:AssumeRole", "Principal": { "Service": "lambda.amazonaws.com" }, "Effect": "Allow", "Sid": "" } ]}EOF}
resource "aws_lambda_function" "restApiLambdaFunction" { filename = var.JAR_PATH function_name = "RestApiFunction" role = aws_iam_role.lambda-execution-role.arn handler = "org.springframework.cloud.function.adapter.aws.FunctionInvoker" runtime = "java11" timeout = 30 source_code_hash = filebase64sha256(var.JAR_PATH)}
resource "aws_api_gateway_rest_api" "rest-api" { name = "ExampleRestApi"}
resource "aws_api_gateway_resource" "proxy" { rest_api_id = aws_api_gateway_rest_api.rest-api.id parent_id = aws_api_gateway_rest_api.rest-api.root_resource_id path_part = "{proxy+}"}
resource "aws_api_gateway_method" "proxy" { rest_api_id = aws_api_gateway_rest_api.rest-api.id resource_id = aws_api_gateway_resource.proxy.id http_method = "ANY" authorization = "NONE"}
resource "aws_api_gateway_integration" "proxy" { rest_api_id = aws_api_gateway_rest_api.rest-api.id resource_id = aws_api_gateway_method.proxy.resource_id http_method = aws_api_gateway_method.proxy.http_method
integration_http_method = "POST" type = "AWS_PROXY" uri = aws_lambda_function.restApiLambdaFunction.invoke_arn}
resource "aws_api_gateway_deployment" "rest-api-deployment" { depends_on = [aws_api_gateway_integration.proxy] rest_api_id = aws_api_gateway_rest_api.rest-api.id stage_name = var.STAGE}
resource "aws_cloudwatch_event_rule" "warmup" { name = "warmup-event-rule" schedule_expression = "rate(10 minutes)"}
resource "aws_cloudwatch_event_target" "warmup" { target_id = "warmup" rule = aws_cloudwatch_event_rule.warmup.name arn = aws_lambda_function.restApiLambdaFunction.arn input = "{\"httpMethod\": \"SCHEDULE\", \"path\": \"warmup\"}"}
resource "aws_lambda_permission" "warmup-permission" { statement_id = "AllowExecutionFromCloudWatch" action = "lambda:InvokeFunction" function_name = aws_lambda_function.restApiLambdaFunction.function_name principal = "events.amazonaws.com" source_arn = aws_cloudwatch_event_rule.warmup.arn}
resource "aws_lambda_function" "exampleFunctionOne" { filename = var.JAR_PATH function_name = "ExampleFunctionOne" role = aws_iam_role.lambda-execution-role.arn handler = "org.localstack.sampleproject.api.LambdaApi" runtime = "java11" timeout = 30 source_code_hash = filebase64sha256(var.JAR_PATH) environment { variables = { FUNCTION_NAME = "functionOne" } }}
resource "aws_lambda_function" "exampleFunctionTwo" { filename = var.JAR_PATH function_name = "ExampleFunctionTwo" role = aws_iam_role.lambda-execution-role.arn handler = "org.localstack.sampleproject.api.LambdaApi" runtime = "java11" timeout = 30 source_code_hash = filebase64sha256(var.JAR_PATH) environment { variables = { FUNCTION_NAME = "functionTwo" } }}
Testing, Debugging and Hot Reloading
Section titled “Testing, Debugging and Hot Reloading”Please read our Lambda Tools documentation to learn more about testing, debugging, and hot reloading for JVM Lambda functions.