Create and Deploy a Micronaut Gateway Function as an Amazon Lambda Function

This guide describes how to use the Graal Development Kit for Micronaut (GDK) to create a Micronaut® Gateway function, deploy it using Amazon Lambda, and access it via Amazon API Gateway. The application is compiled into a native executable using GraalVM Native Image and then built into a container image.

A Micronaut Gateway Function acts a a serverless cloud (HTTP) API gateway function.

Amazon Lambda is a serverless, event-driven compute service that lets you run code for virtually any type of application or backend service without provisioning or managing servers. You can deploy code to AWS Lambda function by uploading a ZIP file, or by creating and uploading a container image. AWS provides several open source base images that you can use to build the container image for your function code.

Prerequisites #

Follow the steps below to create a Micronaut gateway function from scratch. However, you can also download the completed example:

A note regarding your development environment

Consider using Visual Studio Code, which provides native support for developing applications with the Graal Development Kit extension.

If you use IntelliJ IDEA, enable annotation processing.

Windows platform: The GDK guides are compatible with Gradle only. Maven support is coming soon.

1. Create the Gateway Function #

This section describes how to create a Micronaut gateway function for a simple online store. The store provides information about available items and enables the user to order items. An HTTP controller is responsible for the API implementation and a service stores the availability of items.

Create a Micronaut gateway function using the GDK Launcher.

  1. Open the GDK Launcher in advanced mode.

  2. Create a new project using the following selections.
    • Project Type: Gateway Function
    • Project Name: aws-serverless-demo
    • Base Package: com.example (Default)
    • Clouds: AWS
    • Language: Java (Default)
    • Build Tool: Gradle (Groovy) or Maven
    • Test Framework: JUnit (Default)
    • Java Version: 17 (Default)
    • Micronaut Version: (Default)
    • Cloud Services: None
    • Features: GraalVM Native Image and Micronaut Validation
    • Sample Code: No
  3. Click Generate Project, then click Download Zip. The GDK Launcher creates a Micronaut gateway function with the default package com.example in a directory named aws-serverless-demo. The application ZIP file will be downloaded to your default downloads directory. Unzip it, open it in your code editor, and proceed to the next steps.

Alternatively, use the GDK CLI as follows:

gcn create-gateway-function com.example.aws-serverless-demo \
    --clouds=aws \
    --features=graalvm,validation \
    --example-code=false \
    --jdk=17 \
    --build=gradle \
    --lang=java
gcn create-gateway-function com.example.aws-serverless-demo \
    --clouds=aws \
    --features=graalvm,validation \
    --example-code=false \
    --jdk=17 \
    --build=maven \
    --lang=java

For more information, see Using the GDK CLI.

The GDK Launcher creates a multimodule project with two subprojects: aws for Amazon Web Services, and lib. You develop the gateway function logic in the aws subproject. If your gateway function is to be deployed to multiple cloud providers, use the lib subproject to create classes that can be shared between the providers. This enables you to separate the code that is different between cloud providers, while keeping most of the implementation in the common lib subproject.

1.1. StoreItem #

Create a StoreItem model to represent an item in the store in the file lib/src/main/java/com/example/StoreItem.java with the following contents:

package com.example;

import io.micronaut.core.annotation.Introspected;
import io.micronaut.core.annotation.NonNull;

import io.micronaut.serde.annotation.Serdeable;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

@Serdeable
@Introspected // <1>
public class StoreItem {

    @NotBlank
    @NonNull
    private final String name;

    private final String description;

    @Min(0)
    private int numberInStorage;

    public StoreItem(String name, String description, int numberInStorage) { // <2>
        this.name = name;
        this.description = description;
        this.numberInStorage = numberInStorage;
    }

    public String getName() {
        return name;
    }

    public String getDescription() {
        return description;
    }

    public Integer getNumberInStorage() {
        return numberInStorage;
    }

    public void setNumberInStorage(Integer numberInStorage) {
        this.numberInStorage = numberInStorage;
    }
}

1 The @Introspected annotation enables Micronaut to serialize and deserialize the model from different formats including JSON. This provides the ability to use the type inside HTTP requests or responses.

2 The model has fields to store the item’s name, description, and number available.

1.2. StoreController #

Create an HTTP controller in the file lib/src/main/java/com/example/StoreController.java, as follows:

package com.example;

import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.PathVariable;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.exceptions.HttpStatusException;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

import java.util.Collection;
import java.util.stream.Collectors;

import static io.micronaut.http.HttpStatus.BAD_REQUEST;
import static io.micronaut.http.HttpStatus.NOT_FOUND;

@Controller("/store") // <1>
class StoreController {

    private final StorageService storageService;

    StoreController(StorageService storageService) { // <2>
        this.storageService = storageService;
    }

    @Get("/all") // <3>
    Collection<StoreItem> listAllItems() {
        return storageService.getItems();
    }

    @Get("/available") // <4>
    Collection<StoreItem> listAvailableItems() {
        return storageService.getItems().stream()
                .filter(i -> i.getNumberInStorage() > 0)
                .collect(Collectors.toList());
    }

    @Post(uri = "/order/{name}/{amount}", consumes = "*/*") // <5>
    HttpResponse<StoreItem> orderItem(@NotBlank @PathVariable String name, @Min(1) int amount) {
        if (storageService.findItem(name).isEmpty()) {
            throw new HttpStatusException(NOT_FOUND, "Item '" + name + "' not found");
        }
        try {
            storageService.orderItem(name, amount);
        } catch (StorageService.StorageException e) {
            throw new HttpStatusException(BAD_REQUEST, "Could not order item '" + name + "'. " + e.getMessage());
        }
        return HttpResponse.ok(storageService.findItem(name).orElse(null));
    }
}

1 The class is defined as a controller with the @Controller annotation mapped to the path /store.

2 Use Micronaut argument injection to inject a StorageService bean by defining it as the constructor argument. You will create the StorageService in next section.

3 The @Get annotation maps the listAllItems method to an HTTP GET request on /store/all.

4 The @Get annotation maps the listAvailableItems method to an HTTP GET request on /store/available.

5 The @Post annotation maps the orderItem method to an HTTP POST request on /store/order/{name}/{amount}. Use the consumes argument to specify which content-types are allowed in the request. Throwing HttpStatusException will set the corresponding HTTP status in the response.

1.3. StorageService #

  1. Create an interface for a service that represents the store’s inventory in lib/src/main/java/com/example/StorageService.java:

     package com.example;
    
     import io.micronaut.core.annotation.NonNull;
    
     import jakarta.validation.constraints.Min;
     import jakarta.validation.constraints.NotBlank;
     import java.util.Collection;
     import java.util.Optional;
    
     public interface StorageService { // <1>
         Collection<StoreItem> getItems();
         Optional<StoreItem> findItem(@NonNull @NotBlank String name);
         void orderItem(@NonNull @NotBlank String name, @Min(1) int amount);
    
         class StorageException extends RuntimeException { // <2>
             StorageException(String message) {
                 super(message);
             }
         }
     }
    

    1 The storage service provides information about all the items, finds an items by its name, and can place an order for an item.

    2 The class includes a custom exception that thrown in case of invalid requests to storage.

  2. Create an implementation of the service interface in lib/src/main/java/com/example/DefaultStorageService.java:

     package com.example;
    
     import io.micronaut.context.annotation.Requires;
     import io.micronaut.core.annotation.NonNull;
     import jakarta.inject.Singleton;
    
     import java.util.ArrayList;
     import java.util.Collection;
     import java.util.List;
     import java.util.Optional;
    
     @Singleton // <1>
     @Requires(missingBeans = StorageService.class) // <2>
     class DefaultStorageService implements StorageService {
    
         protected List<StoreItem> items = List.of( // <3>
             new StoreItem("chair", "A black chair with 4 legs", 10),
             new StoreItem("table", "A quality dining table", 6),
             new StoreItem("sofa", "A gray sofa", 2),
             new StoreItem("bookshelf", "A futuristic-looking bookshelf", 0)
         );
    
         @Override
         public Collection<StoreItem> getItems() {
             return items;
         }
    
         @Override
         public Optional<StoreItem> findItem(@NonNull String name) {
             return items.stream().filter(item -> item.getName().equals(name)).findFirst();
         }
    
         @Override
         public void orderItem(@NonNull String name, int amount) {
             findItem(name).ifPresentOrElse(item -> {
                 if (item.getNumberInStorage() >= amount) {
                     item.setNumberInStorage(item.getNumberInStorage() - amount);
                 } else {
                     throw new StorageException("Insufficient amount in storage");
                 }
             }, () -> { throw new StorageException("Item not found in storage"); });
         }
     }
    

    1 Use jakarta.inject.Singleton to designate a class as a singleton.

    2 The @Requires(missingBeans = StorageService.class) annotation specifies that this implementation should only be used if no other implementations could be found.

    3 The implementation stores the items in a List and populates some sample items in the list.

If you wish to implement a more advanced StorageService to be used instead of this one, annotate your implementation with @Singleton as shown above. Use the @Requires(env = "ec2") annotation to make it specific to AWS . Visit the Database Module for details about how to store and manipulate data in a database.

1.4. Tests to Verify Gateway Function Logic #

The GDK Launcher created a test class for the controller in aws/src/test/java/com/example/StoreControllerTest.java, as follows:

package com.example;

import io.micronaut.context.annotation.Property;
import io.micronaut.context.annotation.Requires;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Post;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static io.micronaut.http.HttpStatus.BAD_REQUEST;
import static io.micronaut.http.HttpStatus.NOT_FOUND;
import static io.micronaut.http.HttpStatus.OK;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;

@MicronautTest // <1>
@Property(name = "use-test-storage-service", value = "true")
class StoreControllerTest {

    @Inject
    StoreClient client;

    @Test
    void testAvailableItems() {
        List<StoreItem> availableItems = client.getAvailable();

        assertEquals(2, availableItems.size());
        assertEquals("pot", availableItems.get(1).getName());
        assertEquals(10, availableItems.get(1).getNumberInStorage());
        assertNotNull(availableItems.get(1).getDescription());
    }

    @Test
    void testNotFoundException() {
        HttpResponse<?> response = client.order("lamp", 1);

        assertEquals(NOT_FOUND, response.getStatus());
    }

    @Test
    void testNotSufficientException() {
        HttpClientResponseException e = assertThrows(HttpClientResponseException.class, () -> {
            client.order("pot", 100);
        });

        assertEquals(BAD_REQUEST, e.getStatus());
    }

    @Test
    void testOrderRequest() {
        StoreItem plate = client.getAll().stream()
                .filter(i -> i.getName().equals("plate"))
                .findFirst().orElse(null);
        assertNotNull(plate);
        assertEquals(100, plate.getNumberInStorage());

        HttpResponse<StoreItem> response = client.order("plate", 10);
        assertEquals(OK, response.getStatus());
        assertNotNull(response.body());
        assertEquals("plate", response.body().getName());
        assertEquals(90, response.body().getNumberInStorage());
    }

    @Singleton
    @Requires(property = "use-test-storage-service", value = "true")
    static class TestStorageService extends DefaultStorageService {
        TestStorageService() { // <2>
            items = List.of(
                    new StoreItem("plate", "A large plate", 100),
                    new StoreItem("pot", "A cooking pot", 10),
                    new StoreItem("pan", "A large pan", 0)
            );
        }
    }

    @Client("/store") // <3>
    interface StoreClient {
        @Get("/all") // <4>
        List<StoreItem> getAll();

        @Get("/available") // <4>
        List<StoreItem> getAvailable();

        @Post("/order/{name}/{amount}") // <4>
        HttpResponse<StoreItem> order(String name, Integer amount);
    }
}

1 Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context. This enables you to inject beans using the Jakarta @Inject annotation and to send requests to the StoreController defined in the application. Configure this test to use an identified context using the @Property(name = "use-test-storage-service", value = "true") annotation.

2 Create a mock implementation of StorageService so that the test is independent of the current state of the storage. The @Requires(property = "use-test-storage-service", value = "true") means that the bean should only be available if the specified property is set.

3 Create a Micronaut Declarative Client with the same /store path to send requests to the controller.

4 Create three tests using the defined client and assuming that TestStorageService is used.

2. Run the Tests #

Run the tests using the following command:

./gradlew test

Then open the file ec2/build/reports/tests/test/index.html in a browser to view the results.

./mvnw test

Although you created this gateway function to run on the Amazon Lambda Function runtime, the tests should run successfully on your local machine.

Furthermore, Micronaut has a test implementation that simulates the Lambda environment. Since sequential requests to a function may be processed by different instances, Micronaut creates a separate environment for each request in test.

3. Create an Amazon Lambda Function #

The Micronaut framework makes it easier to deploy a function to a Custom AWS Lambda runtime.

3.1. Generate a GraalVM Native Executable #

The following command generates a ZIP file which contains a native executable of the application, generated with GraalVM Native Image, and a bootstrap file which runs that native executable. The native executable of the application is generated inside a container.

Run the command:

./gradlew aws:buildNativeLambda

Note (Only on macOS AArch64): add the following line to the dependencies block of the buildSrc/build.gradle configuration file:

implementation("com.github.docker-java:docker-java-transport-httpclient5:3.3.1")

Modify the aws/pom.xml file to set the following property:

<properties>
    ...
    <exec.mainClass>io.micronaut.function.aws.runtime.MicronautLambdaRuntime</exec.mainClass>
    ...
</properties>

Run the commands:

./mvnw install -pl lib -am
./mvnw package -Dpackaging=docker-native -Pgraalvm -pl aws

3.2. Create a Lambda Function #

  1. Sign in to the AWS Console. Go to Amazon Lambda Functions. Click Create function.

  2. Select Author from scratch, and provide the following information, then click Create function:
    • Enter “gdk-serverless-demo-native” for the name of the function.
    • For the runtime, select Amazon Linux 2023 from the drop-down list.
    • Select x86_64 as the architecture.
  3. Scroll down, in the Code tab, click Upload from, and select .zip file from the drop-down list.

    Choose the archive you created in the previous section (a container image) from the following location (read the logs from generation command if the file is not present):

    aws/build/libs/aws-1.0-SNAPSHOT-lambda.zip

    aws/target/function.zip

    Click Save.

  4. Scroll down within the Code tab. In the Runtime settings section, click Edit. As Handler, enter io.micronaut.function.aws.proxy.MicronautLambdaHandler. Click Save.

3.3. Test the Lambda Function #

  1. In the Lambda Function, click the Test tab. Enter a name for the new test (such as “test”) and paste the following event in the Event JSON field:
    {
      "path": "/store/all",
      "httpMethod": "GET",
      "headers": {
        "Accept": "application/json"
      }
    }
    

    Click Save to save the test. Click Test to run it.

  2. Scroll up. Inside the Execution Result block verify that the response body is as expected.

4. Create an AWS API Gateway #

This section describes how to create an API Gateway to provide access to the function.

  1. Go to API Gateway in the AWS Console. Scroll down to REST API. Click Build.

  2. Select New API. Enter a name for the new API and select Regional from the drop-down list for “API endpoint type”. Click Create API.

  3. Once created, click Create resource. Enter a name for the resource, and enter “/” as the Resource Path and “{proxy+}” as the Resource Name to allow all paths. Click Create Resource.

  4. Select the “/{proxy+}” resource and click Create method. Enter the following information, and then click Save. If a dialog box appears, click OK to give the gateway permission to run the function. Click Save.
    • For the “Method type”, select ANY from the drop-down list.
    • Select Lambda Proxy as the “Integration type”.
    • Select the gdk-serverless-demo-native function from the drop-down list as the name of the Lambda Function.
  5. Click Deploy API.

  6. Select *New Stage* and click Deploy.

  7. In the stage you just created (named “default”), copy the Invoke URL value.

5. Test Lambda Function Gateway Deployment #

Now the application is deployed to AWS, you can test it by sending requests.

  1. Define an environment variable for your gateway hostname:

      export GATEWAY_HOSTNAME=aaaaaaaaaa.execute-api.us-east-2.amazonaws.com/default
    

    Note: On Windows, use set GATEWAY_HOSTNAME=<hostname> to set it and %GATEWAY_HOSTNAME% to access it. Or you can skip this step and paste the hostname to each of the following commands manually.

  2. Use curl to retrieve all the items:

     curl https://$GATEWAY_HOSTNAME/store/all
    
     [
       {
         "name": "chair",
         "description": "A black chair with 4 legs",
         "numberInStorage": 10
       },
       {
         "name": "table",
         "description": "A quality dining table",
         "numberInStorage": 6
       },
       {
         "name": "sofa",
         "description": "A gray sofa",
         "numberInStorage": 2
       },
       {
         "name": "bookshelf",
         "description": "A futuristic-looking bookshelf",
         "numberInStorage": 0
       }
     ]
    
  3. Get an error when attempting to order too many items:

     curl -X POST https://$GATEWAY_HOSTNAME/store/order/table/10
    
     {"message": "Bad Request",
       "_embedded": {
         "errors": [{
           "message": "Could not order item 'table'. Insufficient amount in storage"
         }]
       }, ...
     }
    
  4. Order an item and print the response status code:

     curl -X POST -w "\nStatus code: %{http_code}" https://$GATEWAY_HOSTNAME/store/order/table/6
    
     {
       "name":"table",
       "description":"A quality dining table",
       "numberInStorage":0
     }
     Status code: 200
    
  5. Get the available items:

     curl https://$GATEWAY_HOSTNAME/store/available
    
     [
       {
         "name": "chair",
         "description": "A black chair with 4 legs",
         "numberInStorage": 10
       },
       {
         "name": "sofa",
         "description": "A gray sofa",
         "numberInStorage": 2
       }
     ]
    

Summary #

This guide demonstrated how to create a Micronaut application, compile it into a native executable and containerize, run it as an Amazon Lambda Function, and setup the API Gateway to provide access it.