In this section, we will be adding the user registration feature to our application. First of all, we have to consider some constraints for our application:
- Only one unique email address can be registered per user
- Every user must have an unique username
- Email addresses must be valid
If one of the checks fails, we have to reject the request. For the first two checks, we have to return the 409 Conflict HTTP code, while for the latest one, we have to return 400 Bad Request. API Gateway uses exceptions for status code. This means that our handler should return exceptions for every case, so we can map those exceptions to HTTP error code on the API Gateway level.
From a business logic point of view, the last requirement is easy; we can add a basic regular expression to check the validity of the email address given by the client. On the other hand, for the first two checks, we have to go to our database and check the existing records. The problem at this stage is that we did not create indexes for the Username and Email attributes, so at the moment, the only way is to scan tables, which is a pretty expensive operation, especially for large tables. Then, before we start adding our business logic, we have to modify our table and create indexes for the Username and Email attributes.
Now let's replace UserTable with the following block, which adds UsernameIndex as Global Secondary Index (GSI):
"UserTable": { "Type": "AWS::DynamoDB::Table", "Properties": { "AttributeDefinitions": [ { "AttributeName": "UserId", "AttributeType": "S" }, { "AttributeName": "Username", "AttributeType": "S" } ], "KeySchema": [ { "AttributeName": "UserId", "KeyType": "HASH" } ], "GlobalSecondaryIndexes": [ { "IndexName": "UsernameIndex", "KeySchema": [ { "AttributeName": "Username", "KeyType": "HASH" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1 } } ], "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1 } } }
First of all, note that we added Username to the AttributeDefinitions section. It is needed for any attribute that is indexed because DynamoDB wants to know the type of the attribute upfront-String, in our case. After that, we define a GSI with the Username attribute as HASH, and we adjust Projection to ALL, which means that when we query the table using this index, it will return all the documents, and then we set the read and write provisioned throughput capacity. To understand better how indexes work, it is strongly recommended that you refer to the AWS documentation, but for our use case, this configuration is just sufficient. You might have asked why we defined the new provisioned throughput capacity. That is because global secondary indexes are just behaving as different tables, and they have their own read and write capacity. Of course, it creates an extra cost for every extra index, and you have to predict the potential usage of your indexes and set throughput capacities accordingly.
The second index we need now is EmailIndex. It is almost the same as UsernameIndex, so as the attribute definition, we should add the following:
{ "AttributeName": "Email", "AttributeType": "S" }
Add this definition to Global Secondary Indexes:
{ "IndexName": "EmailIndex", "KeySchema": [ { "AttributeName": "Email", "KeyType": "HASH" } ], "Projection": { "ProjectionType": "ALL" }, "ProvisionedThroughput": { "ReadCapacityUnits": 1, "WriteCapacityUnits": 1 } }
We should deploy to create the second index again.
We have our indexes ready, so now, we can move on to modifying our application. We will start with UserRepository first. Here, we need some methods, such as getUserByEmail, getUserByUsername, and saveUser. The first two methods will fetch users from DynamoDB using different criteria, and the last one will save a new user or update an existing one.
DynamoDB Mapper saves our day again because it supports querying by global secondary indexes. However, a new annotation comes into the scene because DynamoDB Mapper needs to know which attributes correspond to global secondary indexes. If you want, let's start with modifying our User class for these new indexes. The changes are not too big, so we only have to change our field definitions, such as the following:
public class User { ....... @DynamoDBIndexHashKey(globalSecondaryIndexName = "UsernameIndex",
attributeName = "Username") private String username; @DynamoDBIndexHashKey(globalSecondaryIndexName = "EmailIndex",
attributeName = "Email") private String email; .....
}
Now we can create three new methods in our UserRepository interface:
public interface UserRepository { ..... Optional<User> getUserByEmail(String email); Optional<User> getUserByUsername(String username); void saveUser(User user); }
Now we will implement these methods. Let's start with saveUser, which is a one-line method:
public class UserRepositoryDynamoDB implements UserRepository { ...... @Override public void saveUser(User user) { dynamoDBMapper.save(user); } }
Now we can implement the other ones. Querying by global secondary index is a bit different than the primary index, so we need to write a method that will get the index name and the criteria as the parameter and return the result. Now add this method to the UserRepositoryDynamoDB class:
public class UserRepositoryDynamoDB implements UserRepository { ..... public Optional<User> getUserByCriteria(String indexName,
User hashKeyValues) { DynamoDBQueryExpression<User> expression = new
DynamoDBQueryExpression<User>() .withIndexName(indexName) .withConsistentRead(false) .withHashKeyValues(hashKeyValues)
.withLimit(1); QueryResultPage<User> result = dynamoDBMapper.queryPage(User.class,
expression); if (result.getCount() > 0) { return Optional.of(result.getResults().get(0)); } return Optional.empty(); } }
Using this method, it is very easy to fetch users by username or email. Then, we can add these two methods to finish the implementation:
@Override public Optional<User> getUserByEmail(String email) { return getUserByCriteria("EmailIndex", new User().setEmail(email)); } @Override public Optional<User> getUserByUsername(String username) { return getUserByCriteria("UsernameIndex", new
User().setUsername(username)); }
It is convenient to add a test to verify that our repository works correctly. Now create the test folder with this command:
$ mkdir -p services-user/src/test/java/com/
serverlessbook/services/user/repository
Then, create UserRepositoryDynamoDBTest.java with the following content:
package com.serverlessbook.services.user.repository; import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient; import com.serverlessbook.repository.DynamoDBMapperWithCustomTableName; import com.serverlessbook.services.user.domain.User; import org.junit.Test; import java.util.Optional; import java.util.UUID; import static org.junit.Assert.*; public class UserRepositoryDynamoDBTest { private UserRepository getUserRepository() { return new UserRepositoryDynamoDB(new DynamoDBMapperWithCustomTableName(
new AmazonDynamoDBClient())); } @Test public void saveAndRetrieveUser() throws Exception { final String email = "test@test.com"; final String password = "test-password"; final String username = "test-username"; final String id = UUID.randomUUID().toString(); User newUser = new User() .setEmail(email) .setUsername(username) .setId(id); getUserRepository().saveUser(newUser); assertEquals(email, getUserRepository().getUserByEmail
(email).orElseThrow(RuntimeException::new).getEmail()); assertEquals(username,getUserRepository().getUserByUsername
(username).orElseThrow(RuntimeException::new).getUsername()); } }
As you might have noted, this test creates a user with a specific information and then tries to fetch the same user from DynamoDB and tests the availability of the object after querying. This test should pass, so you are good to go. Our repository is working.
Now it is time to implement the business logic. First, let's create the exceptions to be thrown when a check fails. Let's start with creating the com.serverlessbook.services.user.exception package and create the UserRegistrationException class under it:
package com.serverlessbook.services.user.exception; public abstract class UserRegistrationException extends Exception { private static final long serialVersionUID = -7628860081079461234L; protected UserRegistrationException(String message) { super(message); } }
This is our base exception, which will be thrown by UserService. Now, we have to create child exceptions for every case. Let's start with InvalidMailAddressException:
package com.serverlessbook.services.user.exception; public class InvalidMailAddressException extends
UserRegistrationException { private static final long serialVersionUID = 4033382620357808779L; public InvalidMailAddressException() { super("This E-Mail address is not valid"); } }
Then let's add two more exceptions with Java-ish names, first for duplicate usernames:
package com.serverlessbook.services.user.exception; public class AnotherUserWithSameUsernameExistsException extends
UserRegistrationException { private static final long serialVersionUID = 4824390458386666422L; public AnotherUserWithSameUsernameExistsException() { super("Another user with same username already exists."); } }
And then let's add duplicate email addresses:
package com.serverlessbook.services.user.exception;
public class AnotherUserWithSameEmailExistsException extends UserRegistrationException { private static final long serialVersionUID = -7048567407775970663L; public AnotherUserWithSameEmailExistsException() { super("Another user with same E-Mail address already exists."); } }
Now we add the required methods to UserService:
public interface UserService { .... User registerNewUser(String username, String email) throws
UserRegistrationException; }
There is also a practical benefit of such a approach. Sending emails or performing other post-registration processes takes time and the client does not have to wait for them to be completed. So, running them asynchronously will reduce the response time after registration and improve your response time.
Now we can implement the registerNewUser method:
@Override public User registerNewUser(String username, String email) throws
UserRegistrationException { checkEmailValidity(email); checkEmailUniqueness(email); checkUsernameUniqueness(username); User newUser = new User() .setId(UUID.randomUUID().toString()) .setUsername(username) .setEmail(email);
userRepository.saveUser(newUser); return newUser; }
Here, we need three methods that will perform three different checks:
private void checkEmailValidity(String email) throws
InvalidMailAddressException { final String emailPattern = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@((\\[[0-9]{1,3}\\.
[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$"; if (!Pattern.compile(emailPattern).matcher(email).matches()) { throw new InvalidMailAddressException(); }
} void checkEmailUniqueness(String email) throws AnotherUserWithSameEmailExistsException { if (userRepository.getUserByEmail(email).isPresent()) { throw new AnotherUserWithSameEmailExistsException(); } } void checkUsernameUniqueness(String username) throws
AnotherUserWithSameUsernameExistsException { if (userRepository.getUserByUsername(username).isPresent()) { throw new AnotherUserWithSameUsernameExistsException(); } }
These methods just throw the appropriate extensions when the check fails. Now we can create a test for this class and make sure that it works before we upload it to Lambda. Let's create the skeleton for the test first:
package com.serverlessbook.services.user; import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClient; import com.serverlessbook.repository.DynamoDBMapperWithCustomTableName; import com.serverlessbook.services.user.repository.UserRepositoryDynamoDB; import org.junit.Rule; import org.junit.rules.ExpectedException; public class UserServiceImplTest { @Rule public ExpectedException thrown = ExpectedException.none(); private UserService getUserService() { return new UserServiceImpl(new UserRepositoryDynamoDB(new
DynamoDBMapperWithCustomTableName(new AmazonDynamoDBClient()))); } }
Now we can create three different tests for three different failing cases:
@Test public void failedUserRegistrationWithExistingUsernameTest() throws Exception { thrown.expect(AnotherUserWithSameUsernameExistsException.class); UserService userService = getUserService(); final String username = UUID.randomUUID() + "test-username"; userService.registerNewUser(username, UUID.randomUUID() + "@test.com"); //Second call should fail userService.registerNewUser(username, UUID.randomUUID() + "@test.com"); }
The second one is for the existing email addresses:
@Test public void failedUserRegistrationWithExistingEMailTest() throws Exception { thrown.expect(AnotherUserWithSameEmailExistsException.class); UserService userService = getUserService(); final String email = UUID.randomUUID() + "@test.com"; userService.registerNewUser(UUID.randomUUID().toString(), email); //Second call should fail userService.registerNewUser(UUID.randomUUID().toString(), email); }
And finally, we have one for invalid emails:
@Test public void failedUserRegistrationWithInvalidEmailTest() throws Exception { thrown.expect(InvalidMailAddressException.class); UserService userService = getUserService(); userService.registerNewUser(UUID.randomUUID().toString(),
"INVALID_EMAIL"); }
And we are good with tests.