User registration

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:

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.

Before you create another index, you have to deploy your project. This is because of an interesting AWS limitation: At any one time, you can only add one global secondary index. If we add the second index now and try to deploy all the changes at one time, they would fail. That's why, you should deploy it now and wait until the process finishes.

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; }
You might ask why we do not expect a password at this stage, just like all modern web applications. In the next chapter, we will trigger another Lambda function whenever a new user is added to our system. This Lambda function will generate a password for the user and send it via email to the user. Why don't we do this in the registerNewUser function? There are two answers for this question. First, because of the separation of concerns principle. This method should be responsible only for creating the user and persisting it on the database. Whatever you want to do when a new user is registered to you is up to you. In our case, we will send an email, but we may want to do 10 more different things when a user is added. You should create 10 different functions in this case and trigger them via SNS (Simple Notification System), which we will see in the next chapter.

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.

Actually, any seasoned engineer can note that our testing approach has some flaws. These are not unit tests because they use a real implementation of dependent objects or because we are testing against a live database, and so on. All of the criticism is correct; this is not how you test your software. However, the scope of this book is not to teach Java or basic software engineering principles, but the peculiarities of the AWS ecosystem. Therefore, we will keep our tests like this. In your production system, you should consider developing a better structured test approach.