Reactive REST API with AWS DynamoDB and Spring WebFlux
--
In this story, we’re going to implement a reactive REST API using Spring WebFlux and AWS DynamoDB.
· Prerequisites
· Setting Up DynamoDB in AWS Console
· Spring WebFlux API
∘ Creating the Configuration
∘ Creating the Mapping Class
∘ Creating the Repository Classes
∘ Controller class
· Test the REST API:
· Conclusion
· References
Prerequisites
This is the list of all the prerequisites for following this story:
- Java 17
- Starter WebFlux 3.1.0
- Maven 3.6.3
- An active AWS account.
- Optionally, LocalStack to run Dynamodb locally
- Postman or Insomnia
Setting Up DynamoDB in AWS Console
- Log in to the AWS Management Console and open the DynamoDB service.
- Create Table. Add the table name and the primary key. (For this story we are using all other default settings)
3. Create an IAM user to access the DynamoDB tables structure. We need to access DynamoDB programmatically using an access key and a secret access key.
We can also use Localstack to set up DynamoDb locally. It provides an easy-to-use test/mocking framework for developing Cloud applications.
It allows us to Test and debug AWS Cloud Resource Locally.
Spring WebFlux API
Let’s start by creating a simple Spring Reactive project from start.spring.io, with the following dependencies: Spring Reactive Web and Lombok.
Creating the Configuration
First, let us include the DynamoDB Enhanced Client dependency in the pom.xml file.
The DynamoDB enhanced client is a high-level library that is part of the AWS SDK for Java 2.x. It offers a straightforward way to map client-side classes to DynamoDB tables.
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>dynamodb-enhanced</artifactId>
<version>2.20.72</version>
</dependency>
The second important step is to add the aws credentials to connect to AWS DynamoDB in the properties file.
# AWS properties
aws:
access-key: <YOUR ACCESS KEY>
secret-key: <SECRET ACCESS KEY>
region: eu-south-2
endpoint: https://dynamodb.eu-south-2.amazonaws.com
Create a DynamoDbConfig configuration class to initialize the DynamoDbEnhancedAsyncClient bean.
DynamoDbEnhancedAsyncClient
is an asynchronous interface for running commands against a DynamoDb database.
@RequiredArgsConstructor
@Configuration
public class DynamoDbConfig {
private final AwsConfig config;
@Bean
public DynamoDbAsyncClient dynamoDbAsyncClient(){
return DynamoDbAsyncClient.builder().credentialsProvider(StaticCredentialsProvider
.create(AwsBasicCredentials.create(config.getAccessKey(), config.getSecretKey())))
.endpointOverride(URI.create(config.getEndpoint()))
.region(Region.of(config.getRegion()))
.build();
}
@Bean
public DynamoDbEnhancedAsyncClient dynamoDbEnhancedAsyncClient() {
return DynamoDbEnhancedAsyncClient.builder()
.dynamoDbClient(dynamoDbAsyncClient())
.build();
}
}
Creating the Mapping Class
Let us now create the Book and Author classes to represent book and author tables in DynamoDB. At a minimum we must annotate the class so that it can be used as a DynamoDb bean, and also the property that represents the primary partition key of the table.
The following Book
class shows these annotations that will link the class definition to the DynamoDB table.
@DynamoDbBean
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Book {
@Getter(onMethod=@__({@DynamoDbPartitionKey, @DynamoDbAttribute("id")}))
private String id;
@Getter(onMethod=@__({@DynamoDbAttribute("title")}))
private String title;
@Getter(onMethod=@__({@DynamoDbAttribute("page")}))
private Integer page;
@Getter(onMethod=@__({@DynamoDbAttribute("isbn")}))
private String isbn;
@Getter(onMethod=@__({@DynamoDbAttribute("description")}))
private String description;
@Getter(onMethod=@__({@DynamoDbAttribute("language")}))
private String language;
@Getter(onMethod=@__({@DynamoDbAttribute("price")}))
private Double price;
}
The attribute primary partition key must map to a DynamoDb scalar type (string, number, or binary) to be valid. Every mapped table schema must have exactly one of these.
Creating the Repository Classes
Let’s create the Repository class which will interact with the book table to perform CRUD operations.
@Repository
public class BookRepository {
public static final String TABLE_NAME = "book";
private final DynamoDbAsyncTable<Book> bookTable;
public BookRepository(DynamoDbEnhancedAsyncClient dynamoDbClient) {
bookTable = dynamoDbClient
.table(TABLE_NAME, TableSchema.fromBean(Book.class));
}
public Flux<Book> findAll() {
return Flux.from(bookTable.scan().items());
}
public Mono<Book> findById(String id) {
return Mono.fromFuture(bookTable.getItem(getKeyBuild(id)));
}
public Mono<Book> delete(String id) {
return Mono.fromCompletionStage(bookTable.deleteItem(getKeyBuild(id)));
}
public Mono<Integer> count() {
ScanEnhancedRequest scanEnhancedRequest = ScanEnhancedRequest.builder().addAttributeToProject("id").build();
AtomicInteger counter = new AtomicInteger(0);
return Flux.from(bookTable.scan(scanEnhancedRequest))
.doOnNext(page -> counter.getAndAdd(page.items().size()))
.then(Mono.defer(() -> Mono.just(counter.get())));
}
public Mono<Book> update(Book entity) {
var updateRequest = UpdateItemEnhancedRequest.builder(Book.class).item(entity).build();
return Mono.fromCompletionStage(bookTable.updateItem(updateRequest));
}
public Mono<Book> save(Book entity){
entity.setId(UUID.randomUUID().toString());
var putRequest = PutItemEnhancedRequest.builder(Book.class).item(entity).build();
return Mono.fromCompletionStage(bookTable.putItem(putRequest).thenApply(x -> entity));
}
private Key getKeyBuild(String id) {
return Key.builder().partitionValue(id).build();
}
}
We used the DynamoDbEnhancedAsyncClient bean in the repository class to perform async database operations.
Controller class
@RestController
@RequestMapping("/author")
public class AuthorController {
private final AuthorRepository repository;
@Autowired
public AuthorController(AuthorRepository repository) {
this.repository = repository;
}
@GetMapping()
public Mono<ApiResponse> getAllAuthors() {
return repository.findAll()
.collectList()
.map(authors -> new ApiResponse(authors, MessageFormat.format("{0} result found", authors.size())));
}
@GetMapping("/count")
public Mono<ApiResponse> authorCount() {
return repository.count()
.map(count -> new ApiResponse(count, MessageFormat.format("Count authors: {0}", count)));
}
@GetMapping("/{id}")
public Mono<ApiResponse> getByAuthorId(@PathVariable String id) {
return repository.findById(id)
.map(book -> new ApiResponse(book, MessageFormat.format("Result found", book)))
.defaultIfEmpty(new ApiResponse(null, "Author not found"));
}
@PostMapping()
public Mono<ApiResponse> create(@RequestBody Mono<Author> author) {
return author
.flatMap(repository::save)
.map(author1 -> new ApiResponse(author1, "Author successfully created"));
}
@PutMapping("/{id}")
public Mono<ApiResponse> update(@PathVariable String id, @RequestBody Mono<Author> author) {
return author
.map(author1 -> {
author1.setId(id);
return author1;
})
.flatMap(repository::update)
.map(authorUpdated -> new ApiResponse(authorUpdated, "Author successfully updated"));
}
@DeleteMapping("/{id}")
public Mono<ApiResponse> update(@PathVariable String id) {
return repository.delete(id)
.map(authorDeleted -> new ApiResponse(authorDeleted, "Author successfully deleted"));
}
}
Test the REST API:
Run the Application.
- POST /book
- GET all
- Count item
Conclusion
Well done !!.
The complete source code is available on GitHub.
If you enjoyed this story, please give it a few claps for support.
Happy coding!
References
- https://boottechnologies-ci.medium.com/spring-boot-api-crud-with-aws-dynamodb-377e4d5d5a76
- https://github.com/aws/aws-sdk-java-v2/tree/master/services-custom/dynamodb-enhanced#working-with-immutable-data-classes
- https://docs.aws.amazon.com/sdk-for-java/latest/developer-guide/examples-dynamodb-enhanced.html