mn create-app example.micronaut.micronautguide \
--features=dynamodb \
--build=maven \
--lang=java \
--jdk=11
Using DynamoDB in a Micronaut Application
Learn how to use DynamoDB as your persistence solution in a Micronaut Application.
Authors: Sergio del Amo
Micronaut Version: 3.9.2
1. Getting Started
In this guide, we will create a Micronaut application written in Java.
2. DynamoDB
DynamoDB is a fast, flexible NoSQL database service for single-digit millisecond performance at any scale
3. Solution
We recommend that you follow the instructions in the next sections and create the application step by step. However, you can go right to the completed example.
-
Download and unzip the source
4. Writing the Application
4.1. Create application
Create an application using the Micronaut Command Line Interface or with Micronaut Launch.
If you don’t specify the --build argument, Gradle is used as the build tool. If you don’t specify the --lang argument, Java is used as the language.
|
The previous command creates a Micronaut application with the default package example.micronaut
in a directory named micronautguide
.
If you have an existing Micronaut application and want to add the functionality described here, you can view the dependency and configuration changes from the specified features and apply those changes to your application. |
4.2. Dynamo DB Dependencies
To use DynamoDB with the Micronaut framework, your application should have the following dependencies:
<dependency>
<groupId>io.micronaut.aws</groupId>
<artifactId>micronaut-aws-sdk-v2</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb</artifactId>
<scope>compile</scope>
</dependency>
4.3. Id Generation
Create an interface to encapsulate id generation.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
@FunctionalInterface (1)
public interface IdGenerator {
@NonNull
String generate();
}
1 | An interface with one abstract method declaration is known as a functional interface. The compiler verifies that all interfaces annotated with @FunctionInterface really contain one and only one abstract method. |
4.4. Ksuid
Add the following dependency to generate KSUIDs (K-Sortable Globally Unique IDs).
KSUID is for K-Sortable Unique IDentifier. It’s a way to generate globally unique IDs similar to RFC 4122 UUIDs, but contain a time component so they can be "roughly" sorted by time of creation. The remainder of the KSUID is randomly generated bytes.
<dependency>
<groupId>com.github.ksuid</groupId>
<artifactId>ksuid</artifactId>
<scope>compile</scope>
</dependency>
An identifier with a time component is useful when you work with a NoSQL solution such as DynamoDB.
Create a singleton implementation of IdGenerator
.
package example.micronaut;
import com.github.ksuid.Ksuid;
import io.micronaut.context.annotation.Requires;
import io.micronaut.core.annotation.NonNull;
import jakarta.inject.Singleton;
@Requires(classes = Ksuid.class) (1)
@Singleton (2)
public class KsuidGenerator implements IdGenerator {
@Override
@NonNull
public String generate() {
return Ksuid.newKsuid().toString();
}
}
1 | This bean loads only if the specified classes are available. @Requires(classes allows you to provide libraries with |
2 | Use jakarta.inject.Singleton to designate a class as a singleton. |
4.5. POJOs
The application contains an interface to mark classes with a unique identifier.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
public interface Identified {
@NonNull
String getId();
}
Create a POJO to save books to DynamoDB.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.serde.annotation.Serdeable;
import javax.validation.constraints.NotBlank;
@Serdeable (1)
public class Book implements Identified {
@NonNull
@NotBlank (2)
private final String id;
@NonNull
@NotBlank (2)
private final String isbn;
@NonNull
@NotBlank (2)
private final String name;
public Book(@NonNull String id,
@NonNull String isbn,
@NonNull String name) {
this.id = id;
this.isbn = isbn;
this.name = name;
}
@Override
@NonNull
public String getId() {
return id;
}
@NonNull
public String getIsbn() {
return isbn;
}
@NonNull
public String getName() {
return name;
}
}
1 | Declare the @Serdeable annotation at the type level in your source code to allow the type to be serialized or deserialized. |
2 | Use javax.validation.constraints Constraints to ensure the data matches your expectations. |
4.6. Configuration
Define the name of the DynamoDB table in configuration:
dynamodb:
table-name: 'bookcatalogue'
Inject the configuration into the application via a @ConfigurationProperties
bean.
package example.micronaut;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.annotation.ConfigurationProperties;
import javax.validation.constraints.NotBlank;
@Requires(property = "dynamodb.table-name") (1)
@ConfigurationProperties("dynamodb") (2)
public interface DynamoConfiguration {
@NotBlank
String getTableName();
}
1 | This bean loads only if the specified property is defined. |
2 | You can annotate an interface with @ConfigurationProperties to create an immutable configuration. |
4.7. Repository
Create an interface to encapsulate Book
persistence.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import javax.validation.constraints.NotBlank;
import java.util.List;
import java.util.Optional;
public interface BookRepository {
@NonNull
List<Book> findAll();
@NonNull
Optional<Book> findById(@NonNull @NotBlank String id);
void delete(@NonNull @NotBlank String id);
@NonNull
String save(@NonNull @NotBlank String isbn,
@NonNull @NotBlank String name);
}
Create a singleton class to handle common operations with DynamoDB.
package example.micronaut;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.annotation.Primary;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.util.CollectionUtils;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeDefinition;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.BillingMode;
import software.amazon.awssdk.services.dynamodb.model.CreateTableRequest;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemRequest;
import software.amazon.awssdk.services.dynamodb.model.DeleteItemResponse;
import software.amazon.awssdk.services.dynamodb.model.DescribeTableRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemRequest;
import software.amazon.awssdk.services.dynamodb.model.GetItemResponse;
import software.amazon.awssdk.services.dynamodb.model.GlobalSecondaryIndex;
import software.amazon.awssdk.services.dynamodb.model.KeySchemaElement;
import software.amazon.awssdk.services.dynamodb.model.KeyType;
import software.amazon.awssdk.services.dynamodb.model.Projection;
import software.amazon.awssdk.services.dynamodb.model.ProjectionType;
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryResponse;
import software.amazon.awssdk.services.dynamodb.model.ResourceNotFoundException;
import software.amazon.awssdk.services.dynamodb.model.ScalarAttributeType;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@Requires(condition = CIAwsRegionProviderChainCondition.class)
@Requires(condition = CIAwsCredentialsProviderChainCondition.class)
@Requires(beans = { DynamoConfiguration.class, DynamoDbClient.class })
@Singleton
@Primary
public class DynamoRepository<T extends Identified> {
private static final Logger LOG = LoggerFactory.getLogger(DynamoRepository.class);
protected static final String HASH = "#";
protected static final String ATTRIBUTE_PK = "pk";
protected static final String ATTRIBUTE_SK = "sk";
protected static final String ATTRIBUTE_GSI_1_PK = "GSI1PK";
protected static final String ATTRIBUTE_GSI_1_SK = "GSI1SK";
protected static final String INDEX_GSI_1 = "GSI1";
protected final DynamoDbClient dynamoDbClient;
protected final DynamoConfiguration dynamoConfiguration;
public DynamoRepository(DynamoDbClient dynamoDbClient,
DynamoConfiguration dynamoConfiguration) {
this.dynamoDbClient = dynamoDbClient;
this.dynamoConfiguration = dynamoConfiguration;
}
public boolean existsTable() {
try {
dynamoDbClient.describeTable(DescribeTableRequest.builder()
.tableName(dynamoConfiguration.getTableName())
.build());
return true;
} catch (ResourceNotFoundException e) {
return false;
}
}
public void createTable() {
dynamoDbClient.createTable(CreateTableRequest.builder()
.attributeDefinitions(AttributeDefinition.builder()
.attributeName(ATTRIBUTE_PK)
.attributeType(ScalarAttributeType.S)
.build(),
AttributeDefinition.builder()
.attributeName(ATTRIBUTE_SK)
.attributeType(ScalarAttributeType.S)
.build(),
AttributeDefinition.builder()
.attributeName(ATTRIBUTE_GSI_1_PK)
.attributeType(ScalarAttributeType.S)
.build(),
AttributeDefinition.builder()
.attributeName(ATTRIBUTE_GSI_1_SK)
.attributeType(ScalarAttributeType.S)
.build())
.keySchema(Arrays.asList(KeySchemaElement.builder()
.attributeName(ATTRIBUTE_PK)
.keyType(KeyType.HASH)
.build(),
KeySchemaElement.builder()
.attributeName(ATTRIBUTE_SK)
.keyType(KeyType.RANGE)
.build()))
.billingMode(BillingMode.PAY_PER_REQUEST)
.tableName(dynamoConfiguration.getTableName())
.globalSecondaryIndexes(gsi1())
.build());
}
@NonNull
public QueryRequest findAllQueryRequest(@NonNull Class<?> cls,
@Nullable String beforeId,
@Nullable Integer limit) {
QueryRequest.Builder builder = QueryRequest.builder()
.tableName(dynamoConfiguration.getTableName())
.indexName(INDEX_GSI_1)
.scanIndexForward(false);
if (limit != null) {
builder.limit(limit);
}
if (beforeId == null) {
return builder.keyConditionExpression("#pk = :pk")
.expressionAttributeNames(Collections.singletonMap("#pk", ATTRIBUTE_GSI_1_PK))
.expressionAttributeValues(Collections.singletonMap(":pk",
classAttributeValue(cls)))
.build();
} else {
return builder.keyConditionExpression("#pk = :pk and #sk < :sk")
.expressionAttributeNames(CollectionUtils.mapOf("#pk", ATTRIBUTE_GSI_1_PK, "#sk", ATTRIBUTE_GSI_1_SK))
.expressionAttributeValues(CollectionUtils.mapOf(":pk",
classAttributeValue(cls),
":sk",
id(cls, beforeId)
))
.build();
}
}
protected void delete(@NonNull @NotNull Class<?> cls, @NonNull @NotBlank String id) {
AttributeValue pk = id(cls, id);
DeleteItemResponse deleteItemResponse = dynamoDbClient.deleteItem(DeleteItemRequest.builder()
.tableName(dynamoConfiguration.getTableName())
.key(CollectionUtils.mapOf(ATTRIBUTE_PK, pk, ATTRIBUTE_SK, pk))
.build());
if (LOG.isDebugEnabled()) {
LOG.debug(deleteItemResponse.toString());
}
}
protected Optional<Map<String, AttributeValue>> findById(@NonNull @NotNull Class<?> cls, @NonNull @NotBlank String id) {
AttributeValue pk = id(cls, id);
GetItemResponse getItemResponse = dynamoDbClient.getItem(GetItemRequest.builder()
.tableName(dynamoConfiguration.getTableName())
.key(CollectionUtils.mapOf(ATTRIBUTE_PK, pk, ATTRIBUTE_SK, pk))
.build());
return !getItemResponse.hasItem() ? Optional.empty() : Optional.of(getItemResponse.item());
}
@NonNull
public static Optional<String> lastEvaluatedId(@NonNull QueryResponse response,
@NonNull Class<?> cls) {
if (response.hasLastEvaluatedKey()) {
Map<String, AttributeValue> item = response.lastEvaluatedKey();
if (item != null && item.containsKey(ATTRIBUTE_PK)) {
return id(cls, item.get(ATTRIBUTE_PK));
}
}
return Optional.empty();
}
private static GlobalSecondaryIndex gsi1() {
return GlobalSecondaryIndex.builder()
.indexName(INDEX_GSI_1)
.keySchema(KeySchemaElement.builder()
.attributeName(ATTRIBUTE_GSI_1_PK)
.keyType(KeyType.HASH)
.build(), KeySchemaElement.builder()
.attributeName(ATTRIBUTE_GSI_1_SK)
.keyType(KeyType.RANGE)
.build())
.projection(Projection.builder()
.projectionType(ProjectionType.ALL)
.build())
.build();
}
@NonNull
protected Map<String, AttributeValue> item(@NonNull T entity) {
Map<String, AttributeValue> item = new HashMap<>();
AttributeValue pk = id(entity.getClass(), entity.getId());
item.put(ATTRIBUTE_PK, pk);
item.put(ATTRIBUTE_SK, pk);
item.put(ATTRIBUTE_GSI_1_PK, classAttributeValue(entity.getClass()));
item.put(ATTRIBUTE_GSI_1_SK, pk);
return item;
}
@NonNull
protected static AttributeValue classAttributeValue(@NonNull Class<?> cls) {
return AttributeValue.builder()
.s(cls.getSimpleName())
.build();
}
@NonNull
protected static AttributeValue id(@NonNull Class<?> cls,
@NonNull String id) {
return AttributeValue.builder()
.s(String.join(HASH, cls.getSimpleName().toUpperCase(), id))
.build();
}
@NonNull
protected static Optional<String> id(@NonNull Class<?> cls,
@NonNull AttributeValue attributeValue) {
String str = attributeValue.s();
String substring = cls.getSimpleName().toUpperCase() + HASH;
return str.startsWith(substring) ? Optional.of(str.substring(substring.length())) : Optional.empty();
}
}
And an implementation of BookRepository
.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.util.CollectionUtils;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
import software.amazon.awssdk.services.dynamodb.model.AttributeValue;
import software.amazon.awssdk.services.dynamodb.model.PutItemRequest;
import software.amazon.awssdk.services.dynamodb.model.PutItemResponse;
import software.amazon.awssdk.services.dynamodb.model.QueryRequest;
import software.amazon.awssdk.services.dynamodb.model.QueryResponse;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Singleton (1)
public class DefaultBookRepository extends DynamoRepository<Book> implements BookRepository {
private static final Logger LOG = LoggerFactory.getLogger(DefaultBookRepository.class);
private static final String ATTRIBUTE_ID = "id";
private static final String ATTRIBUTE_ISBN = "isbn";
private static final String ATTRIBUTE_NAME = "name";
private final IdGenerator idGenerator;
public DefaultBookRepository(DynamoDbClient dynamoDbClient,
DynamoConfiguration dynamoConfiguration,
IdGenerator idGenerator) {
super(dynamoDbClient, dynamoConfiguration);
this.idGenerator = idGenerator;
}
@Override
@NonNull
public String save(@NonNull @NotBlank String isbn,
@NonNull @NotBlank String name) {
String id = idGenerator.generate();
save(new Book(id, isbn, name));
return id;
}
protected void save(@NonNull @NotNull @Valid Book book) {
PutItemResponse itemResponse = dynamoDbClient.putItem(PutItemRequest.builder()
.tableName(dynamoConfiguration.getTableName())
.item(item(book))
.build());
if (LOG.isDebugEnabled()) {
LOG.debug(itemResponse.toString());
}
}
@Override
@NonNull
public Optional<Book> findById(@NonNull @NotBlank String id) {
return findById(Book.class, id)
.map(this::bookOf);
}
@Override
public void delete(@NonNull @NotBlank String id) {
delete(Book.class, id);
}
@Override
@NonNull
public List<Book> findAll() {
List<Book> result = new ArrayList<>();
String beforeId = null;
do {
QueryRequest request = findAllQueryRequest(Book.class, beforeId, null);
QueryResponse response = dynamoDbClient.query(request);
if (LOG.isTraceEnabled()) {
LOG.trace(response.toString());
}
result.addAll(parseInResponse(response));
beforeId = lastEvaluatedId(response, Book.class).orElse(null);
} while(beforeId != null); (2)
return result;
}
private List<Book> parseInResponse(QueryResponse response) {
List<Map<String, AttributeValue>> items = response.items();
List<Book> result = new ArrayList<>();
if (CollectionUtils.isNotEmpty(items)) {
for (Map<String, AttributeValue> item : items) {
result.add(bookOf(item));
}
}
return result;
}
@NonNull
private Book bookOf(@NonNull Map<String, AttributeValue> item) {
return new Book(item.get(ATTRIBUTE_ID).s(),
item.get(ATTRIBUTE_ISBN).s(),
item.get(ATTRIBUTE_NAME).s());
}
@Override
@NonNull
protected Map<String, AttributeValue> item(@NonNull Book book) {
Map<String, AttributeValue> result = super.item(book);
result.put(ATTRIBUTE_ID, AttributeValue.builder().s(book.getId()).build());
result.put(ATTRIBUTE_ISBN, AttributeValue.builder().s(book.getIsbn()).build());
result.put(ATTRIBUTE_NAME, AttributeValue.builder().s(book.getName()).build());
return result;
}
}
1 | Use jakarta.inject.Singleton to designate a class as a singleton. |
2 | Paginate instead of using a scan operation. |
5. Controllers
Create a CRUD controller for Book
.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.annotation.*;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.scheduling.TaskExecutors;
import io.micronaut.scheduling.annotation.ExecuteOn;
import javax.validation.constraints.NotBlank;
import java.util.List;
import java.util.Optional;
@ExecuteOn(TaskExecutors.IO) (1)
@Controller("/books") (2)
public class BooksController {
private final BookRepository bookRepository;
public BooksController(BookRepository bookRepository) { (3)
this.bookRepository = bookRepository;
}
@Get (4)
public List<Book> index() {
return bookRepository.findAll();
}
@Post (5)
public HttpResponse<?> save(@Body("isbn") @NonNull @NotBlank String isbn, (6)
@Body("name") @NonNull @NotBlank String name) {
String id = bookRepository.save(isbn, name);
return HttpResponse.created(UriBuilder.of("/books").path(id).build());
}
@Get("/{id}") (7)
public Optional<Book> show(@PathVariable @NonNull @NotBlank String id) { (8)
return bookRepository.findById(id);
}
@Delete("/{id}") (9)
@Status(HttpStatus.NO_CONTENT) (10)
public void delete(@PathVariable @NonNull @NotBlank String id) {
bookRepository.delete(id);
}
}
1 | It is critical that any blocking I/O operations (such as fetching the data from the database) are offloaded to a separate thread pool that does not block the Event loop. |
2 | The class is defined as a controller with the @Controller annotation mapped to the path /books . |
3 | Use constructor injection to inject a bean of type BookRepository . |
4 | The @Get annotation maps the index method to an HTTP GET request on / . |
5 | The @Post annotation maps the save method to an HTTP POST request on / . |
6 | You can use a qualifier within the HTTP request body. For example, you can use a reference to a nested JSON attribute. |
7 | The @Get annotation maps the show method to an HTTP GET request on /{id} . |
8 | You can define path variables with a RFC-6570 URI template in the HTTP Method annotation value. The method argument can optionally be annotated with @PathVariable . |
9 | The @Post annotation maps the delete method to an HTTP POST request on / . |
10 | You can return void in your controller’s method and specify the HTTP status code via the @Status annotation. |
5.1. Running the application
Under development and testing, we have configured Test Resources to supply the properties dynamodb-local.host
and dynamodb-port
.
test-resources:
containers:
dynamodb:
image-name: amazon/dynamodb-local (1)
hostnames:
- dynamodb-local.host (2)
exposed-ports:
- dynamodb-local.port: 8000 (3)
1 | The docker image to use for local dynamodb. |
2 | The property to bind the container hostname into. |
3 | The property to bind the container port into, and the port that should be exposed. |
This will start DynamoDB in a container via TestContainers, and inject the properties into your application.
5.1.1. Dev default environment
Modify Application
to use dev
as a default environment.
package example.micronaut;
import io.micronaut.context.ApplicationContextBuilder;
import io.micronaut.context.ApplicationContextConfigurer;
import io.micronaut.context.annotation.ContextConfigurer;
import io.micronaut.context.env.Environment;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.runtime.Micronaut;
public class Application {
@ContextConfigurer
public static class DefaultEnvironmentConfigurer implements ApplicationContextConfigurer {
@Override
public void configure(@NonNull ApplicationContextBuilder builder) {
builder.defaultEnvironments(Environment.DEVELOPMENT);
}
}
public static void main(String[] args) {
Micronaut.run(Application.class, args);
}
}
5.1.2. Dev Bootstrap
Create a StartupEventListener
that is loaded only for the dev
environment that creates a dynamodb table if one does not already exist.
package example.micronaut;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.env.Environment;
import io.micronaut.context.event.ApplicationEventListener;
import io.micronaut.context.event.StartupEvent;
import jakarta.inject.Singleton;
@Requires(property = "dynamodb-local.host") (1)
@Requires(property = "dynamodb-local.port") (1)
@Requires(env = Environment.DEVELOPMENT) (2)
@Singleton (3)
public class DevBootstrap implements ApplicationEventListener<StartupEvent> {
private final DynamoRepository<? extends Identified> dynamoRepository;
public DevBootstrap(DynamoRepository<? extends Identified> dynamoRepository) {
this.dynamoRepository = dynamoRepository;
}
@Override
public void onApplicationEvent(StartupEvent event) {
if (!dynamoRepository.existsTable()) {
dynamoRepository.createTable();
}
}
}
1 | This bean loads only if the specified property is defined. |
2 | This bean loads only if the specified environment is detected. |
3 | Use jakarta.inject.Singleton to designate a class as a singleton. |
5.1.3. Pointing to DynamoDB Local
Add a bean-created listener that points the DynamoDB client to the URL of the Dynamodb local instance.
package example.micronaut;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.annotation.Value;
import io.micronaut.context.event.BeanCreatedEvent;
import io.micronaut.context.event.BeanCreatedEventListener;
import io.micronaut.context.exceptions.ConfigurationException;
import jakarta.inject.Singleton;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.services.dynamodb.DynamoDbClientBuilder;
import java.net.URI;
import java.net.URISyntaxException;
@Requires(property = "dynamodb-local.host") (1)
@Requires(property = "dynamodb-local.port") (1)
@Singleton (2)
class DynamoDbClientBuilderListener
implements BeanCreatedEventListener<DynamoDbClientBuilder> { (3)
private final URI endpoint;
private final String accessKeyId;
private final String secretAccessKey;
DynamoDbClientBuilderListener(@Value("${dynamodb-local.host}") String host, (4)
@Value("${dynamodb-local.port}") String port) { (4)
try {
this.endpoint = new URI("http://" + host + ":" + port);
} catch (URISyntaxException e) {
throw new ConfigurationException("dynamodb.endpoint not a valid URI");
}
this.accessKeyId = "fakeMyKeyId";
this.secretAccessKey = "fakeSecretAccessKey";
}
@Override
public DynamoDbClientBuilder onCreated(BeanCreatedEvent<DynamoDbClientBuilder> event) {
return event.getBean().endpointOverride(endpoint)
.credentialsProvider(() -> new AwsCredentials() {
@Override
public String accessKeyId() {
return accessKeyId;
}
@Override
public String secretAccessKey() {
return secretAccessKey;
}
});
}
}
1 | This bean loads only if the specified property is defined. |
2 | Use jakarta.inject.Singleton to designate a class as a singleton. |
3 | Creating a @Singleton that implements BeanCreatedEventListener allows you to provide extra configuration to |
4 | You can inject configuration values into beans using the @Value annotation. The @Value annotation accepts a string that can have embedded placeholder values (the default value can be provided by specifying a value after the colon : character). |
6. Running the Application
To run the application, use the ./mvnw mn:run
command, which starts the application on port 8080.
You should be able to execute the following cURL requests.
curl http://localhost:8080/books
[]
curl -X POST -d '{"isbn":"1680502395","name":"Release It!"}' -H "Content-Type: application/json" http://localhost:8080/books
curl http://localhost:8080/books
[{"id":"2BLCWltdt3gGgSw1qsomXIfXBiX","isbn":"1680502395","name":"Release It!"}]
6.1. Tests
Create a StartupEventListener
only loaded for the test
environment which creates the dynamodb table if it does not exist.
package example.micronaut;
import io.micronaut.context.annotation.Requires;
import io.micronaut.context.env.Environment;
import io.micronaut.context.event.ApplicationEventListener;
import io.micronaut.context.event.StartupEvent;
import jakarta.inject.Singleton;
@Requires(property = "dynamodb-local.host")
@Requires(property = "dynamodb-local.port")
@Requires(env = Environment.TEST)
@Singleton
public class TestBootstrap implements ApplicationEventListener<StartupEvent> {
private final DynamoRepository dynamoRepository;
public TestBootstrap(DynamoRepository dynamoRepository) {
this.dynamoRepository = dynamoRepository;
}
@Override
public void onApplicationEvent(StartupEvent event) {
if (!dynamoRepository.existsTable()) {
dynamoRepository.createTable();
}
}
}
Create a test which verifies the CRUD functionality.
package example.micronaut;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.type.Argument;
import io.micronaut.core.util.CollectionUtils;
import io.micronaut.http.HttpHeaders;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.annotation.Client;
import io.micronaut.http.uri.UriBuilder;
import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;
@MicronautTest (1)
@TestInstance(PER_CLASS) (2)
class BooksControllerTest { (3)
@Inject
@Client("/")
HttpClient httpClient; (4)
private static HttpRequest<?> saveRequest(String isbn, String name) {
return HttpRequest.POST("/books",
CollectionUtils.mapOf("isbn", isbn, "name", name));
}
@Test
void testRetrieveBooks() {
BlockingHttpClient client = httpClient.toBlocking();
String releaseItIsbn = "1680502395";
String releaseItName = "Release It!";
HttpResponse<?> saveResponse = client.exchange(
saveRequest(releaseItIsbn, releaseItName));
assertEquals(HttpStatus.CREATED, saveResponse.status());
String location = saveResponse.getHeaders().get(HttpHeaders.LOCATION);
assertNotNull(location);
assertTrue(location.startsWith("/books/"));
String releaseItId = location.substring("/books/".length());
String continuousDeliveryIsbn = "0321601912";
String continuousDeliveryName = "Continuous Delivery";
saveResponse = client.exchange(
saveRequest(continuousDeliveryIsbn, continuousDeliveryName));
assertEquals(HttpStatus.CREATED, saveResponse.status());
location = saveResponse.getHeaders().get(HttpHeaders.LOCATION);
assertNotNull(location);
assertTrue(location.startsWith("/books/"));
String continuousDeliveryId = location.substring("/books/".length());
String buildingMicroservicesIsbn = "1491950358";
String buildingMicroservicesName = "Building Microservices";
saveResponse = client.exchange(
saveRequest(buildingMicroservicesIsbn, buildingMicroservicesName));
assertEquals(HttpStatus.CREATED, saveResponse.status());
location = saveResponse.getHeaders().get(HttpHeaders.LOCATION);
assertNotNull(location);
assertTrue(location.startsWith("/books/"));
String buildingMicroservicesId = location.substring("/books/".length());
Book result = client.retrieve(
HttpRequest.GET(UriBuilder.of("/books")
.path(continuousDeliveryId)
.build()), Book.class);
assertEquals(continuousDeliveryName, result.getName());
List<Book> books = client.retrieve(HttpRequest.GET("/books"),
Argument.listOf(Book.class));
assertEquals(3, books.size());
assertTrue(books.stream().anyMatch(it ->
it.getIsbn().equals(continuousDeliveryIsbn) &&
it.getName().equals(continuousDeliveryName)));
assertTrue(books.stream().anyMatch(it ->
it.getIsbn().equals(releaseItIsbn) &&
it.getName().equals(releaseItName)));
assertTrue(books.stream().anyMatch(it ->
it.getIsbn().equals(buildingMicroservicesIsbn) &&
it.getName().equals(buildingMicroservicesName)));
HttpResponse<?> deleteResponse = client.exchange(
HttpRequest.DELETE(UriBuilder.of("/books")
.path(continuousDeliveryId)
.build().toString()));
assertEquals(HttpStatus.NO_CONTENT, deleteResponse.getStatus());
deleteResponse = client.exchange(HttpRequest.DELETE(UriBuilder.of("/books")
.path(releaseItId)
.build().toString()));
assertEquals(HttpStatus.NO_CONTENT, deleteResponse.getStatus());
deleteResponse = client.exchange(HttpRequest.DELETE(UriBuilder.of("/books")
.path(buildingMicroservicesId)
.build().toString()));
assertEquals(HttpStatus.NO_CONTENT, deleteResponse.getStatus());
}
}
1 | Annotate the class with @MicronautTest so the Micronaut framework will initialize the application context and the embedded server. More info. |
2 | Container will be started before and stopped after each test method. Containers declared as static fields will be shared between test methods. They will be started only once before any test method is executed and stopped after the last test method has executed. |
3 | Classes that implement TestPropertyProvider must use this annotation to create a single class instance for all tests (not necessary in Spock tests). |
4 | Inject the HttpClient bean and point it to the embedded server. |
7. Testing the Application
To run the tests:
./mvnw test
8. Next steps
Explore more features with Micronaut Guides.
Check Micronaut AWS integration.
9. Help with the Micronaut Framework
The Micronaut Foundation sponsored the creation of this Guide. A variety of consulting and support services are available.