First of, happy New Year 2022! I hope you started the year better than me, as it immediately came with some illness. Now that I’m better again though, I’m happy to start with an article!
Previously, we’ve dabbled a little with adding entities, most notably accounts. However, in an actual application, those accounts belong to somebody, and only that somebody is supposed to view these entities. So we’ll add the User concept now. The security, authentication and authorization will follow in one of the next posts.
In the next article, I’ll add the accounts to the users. For now, let’s simply create the user and allow a couple of operations on them.
User information
Obviously, we’d like our users to have an account. Now, the information that’s required on the user may vary depending on your needs, but we do need something to identity the user. This could be username and password, a Google or Facebook account, a Metamask ID, it doesn’t really matter.
Let’s start with requiring only an email address, and then assigning them some ID, in domain/entities/User.kt
:
@Entity
@Table(name = "users")
data class User(@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: UUID? = null,
@Column(name = "email", nullable = false) @NotNull val email: String,
@Column(name = "username") val username: String?
)
However, we already sort of know that we’ll want to give the user some customization of the application in the future. For instance,
we may want to give him the choice of language in the app, or to view all his accounts in his preferred currency. There’s also
dark mode to think of, for example. Let’s add this in the same file as the User
itself, and give it some reasonable defaults:
@Entity
@Table(name = "preferences")
data class UserPreferences(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: UUID? = null,
@Column(name = "language") val locale: Locale = Locale.UK,
@Column(name = "currency") val currency: Currency = Currency.getInstance("EUR")
)
Now, we need to map one to the other. We always need to think about how we would like to access the information. Will we ever
have the instance of the preferences, and need to user from that? Hardly likely. The other way around though, that will absolutely$
be required. So let’s add the one-to-one direction in the User
, and instantiate any preferences as default.
@Entity
@Table(name = "users")
data class User(@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: UUID? = null,
@Column(name = "email", nullable = false) @NotNull val email: String,
@Column(name = "username") val username: String?,
@OneToOne(cascade = [CascadeType.ALL]) @JoinColumn(name = "preferences_id") val preferences: UserPreferences = UserPreferences()
)
Wonderful! Now that this is all taken care of, we simply need to give the user the option to actually register, as well as change his preferences. The preferences have an ID, but they’re not particularly useful - they are only required to make the entries unique. So that ID can mostly be ignored.
User operations
So here are the other classes to create and retrieve users, as well as retrieve and update their preferences.
First the DTOs and the extension methods:
data class UserDto(
val id: UUID? = null,
val username: String? = null,
@NotNull @Email val email: String,
val preferences: PreferencesDto? = null
)
data class PreferencesDto(
val locale: Locale,
val currency: Currency,
val darkMode: Boolean
)
fun UserDto.toDomain() = User(
email = this.email,
username = this.username
)
fun User.toDto() = UserDto(
id = this.id,
email = this.email,
username = this.username,
preferences = this.preferences.toDto()
)
fun UserPreferences.toDto() = PreferencesDto(
currency = this.currency,
locale = this.locale,
darkMode = this.darkMode
)
fun PreferencesDto.toDomain() = UserPreferences(
currency = this.currency,
locale = this.locale,
darkMode = this.darkMode
)
The repository, service and controller:
@Repository
interface UserRepository : JpaRepository<User, UUID> {
}
@Service
class UserService @Inject constructor(private val userRepository: UserRepository) {
fun createUser(user: User): User {
return userRepository.save(user);
}
fun getUser(userId: UUID): User {
return userRepository.findById(userId).orElseThrow { UserNotFoundException() }
}
fun updatePreferences(userId: UUID, newPreferences: UserPreferences) {
val user = getUser(userId)
val updatedUser = user.copy(preferences = newPreferences)
userRepository.save(updatedUser)
}
}
@RestController
@RequestMapping("/users")
class UserController @Inject constructor(private val userService: UserService) {
@PostMapping
fun createUser(@RequestBody userDto: UserDto): ResponseEntity<UUID> {
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(userDto.toDomain()).id)
}
@GetMapping("/{userId}")
fun getUser(@PathVariable("userId") userId: UUID): ResponseEntity<UserDto> {
return ResponseEntity.ok(userService.getUser(userId).toDto())
}
@PutMapping("/{userId}/preferences")
fun updatePreferences(@PathVariable("userId") userId: UUID, @RequestBody preferencesDto: PreferencesDto): ResponseEntity<Nothing> {
userService.updatePreferences(userId, preferencesDto.toDomain())
return ResponseEntity.ok().build();
}
}
And of course, to make it work, the liquibase changelog 1-1-2-changelog-users.xml
:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.3.xsd"
objectQuotingStrategy="QUOTE_ONLY_RESERVED_WORDS">
<changeSet id="1641762091266-1" author="cedric (generated)">
<createTable tableName="preferences">
<column autoIncrement="true" name="id" type="BIGINT">
<constraints nullable="false" primaryKey="true" primaryKeyName="pk_preferences"/>
</column>
<column name="language" type="VARCHAR(255)"/>
<column name="currency" type="VARCHAR(255)"/>
<column name="dark_mode" type="BOOLEAN"/>
</createTable>
</changeSet>
<changeSet id="1641762091266-2" author="cedric (generated)">
<createTable tableName="users">
<column name="id" type="UUID">
<constraints nullable="false" primaryKey="true" primaryKeyName="pk_users"/>
</column>
<column name="email" type="VARCHAR(255)">
<constraints nullable="false"/>
</column>
<column name="preferences_id" type="BIGINT"/>
<column name="username" type="VARCHAR(255)"/>
</createTable>
</changeSet>
<changeSet id="1641762091266-3" author="cedric (generated)">
<addUniqueConstraint columnNames="email" constraintName="uc_users_email" tableName="users"/>
</changeSet>
<changeSet id="1641762091266-4" author="cedric (generated)">
<addForeignKeyConstraint baseColumnNames="preferences_id" baseTableName="users"
constraintName="FK_USERS_ON_PREFERENCES" referencedColumnNames="id"
referencedTableName="preferences"/>
</changeSet>
</databaseChangeLog>
Unit tests
Wonderful, all the classes seem to be set. Since we want to work by the book though, we need the unit tests to cover all the new code.
First, the Controller:
@WebMvcTest(UserController::class)
@ActiveProfiles("test")
internal class UserControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
@MockkBean
private lateinit var userService: UserService
private val objectMapper = jacksonObjectMapper()
@Test
fun getUser() {
val id = UUID.randomUUID()
every { userService.getUser(any()) } returns UserFixture.user(id = id)
mockMvc.perform(
MockMvcRequestBuilders.get("/users/$id")
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(MockMvcResultMatchers.status().isOk)
.andExpect(MockMvcResultMatchers.jsonPath("$.email", CoreMatchers.`is`("me@mail.com")))
.andExpect(MockMvcResultMatchers.jsonPath("$.id", CoreMatchers.`is`(id.toString())))
assertAll(
{ verify(exactly = 1) { userService.getUser(id) } }
)
}
@Test
fun getUser_userNotFound_404Returned() {
val id = UUID.randomUUID()
every { userService.getUser(any()) } throws UserNotFoundException()
mockMvc.perform(
MockMvcRequestBuilders.get("/users/$id")
.contentType(MediaType.APPLICATION_JSON)
)
.andExpect(MockMvcResultMatchers.status().isNotFound)
}
@Test
fun createUser() {
val capturedUser = slot<User>()
val createdUser = UserFixture.user(email = "special@mail.com")
val userDto = UserDtoFixture.userDto(email = "special@mail.com")
every { userService.createUser(capture(capturedUser)) } returns createdUser
mockMvc.perform(
MockMvcRequestBuilders.post("/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(userDto))
)
.andExpect(MockMvcResultMatchers.status().isCreated)
.andExpect(MockMvcResultMatchers.content().string("\"${createdUser.id.toString()}\""))
assertAll(
{ assertThat(capturedUser.captured.email).isEqualTo("special@mail.com") }
)
}
@Test
fun updateUserPreferences() {
val capturedPreferences = slot<UserPreferences>()
val user = UserFixture.user()
val preferencesDto = UserDtoFixture.preferences(locale = Locale.FRANCE)
every { userService.updatePreferences(any(), capture(capturedPreferences)) } just runs
mockMvc.perform(
MockMvcRequestBuilders.put("/users/${user.id}/preferences")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(preferencesDto))
)
.andExpect(MockMvcResultMatchers.status().isNoContent)
assertAll(
{ assertThat(capturedPreferences.captured.locale).isEqualTo(Locale.FRANCE) }
)
}
}
Next, the Service:
@ExtendWith(MockKExtension::class)
internal class UserServiceTest {
@MockK
private lateinit var userRepository: UserRepository
@InjectMockKs
private lateinit var userService: UserService
@Test
fun createUser() {
val user = user()
every { userRepository.save(any()) } returns user
userService.createUser(user)
assertAll(
{ verify(exactly = 1) { userRepository.save(user) } }
)
}
@Test
fun getUser() {
val id = UUID.randomUUID()
every { userRepository.findById(any()) } returns Optional.of(user())
userService.getUser(id)
org.junit.jupiter.api.assertAll(
{ verify(exactly = 1) { userRepository.findById(id) } }
)
}
@Test
fun getUser_notFound_exceptionIsThrown() {
every { userRepository.findById(any()) } returns Optional.empty()
assertThrows<UserNotFoundException> { userService.getUser(UUID.randomUUID()) }
}
@Test
fun updatePreferences() {
val updatedUser = slot<User>()
val user = user()
val newPreferences = preferences(darkMode = false, locale = Locale.GERMAN, currency = Currency.getInstance("USD"))
every { userRepository.findById(any()) } returns Optional.of(user)
every { userRepository.save(capture(updatedUser)) } returns user
userService.updatePreferences(user.id!!, newPreferences)
assertAll(
{ verify(exactly = 1) { userRepository.save(any()) } },
{ assertThat(updatedUser.captured.id).isEqualTo(user.id) },
{ assertThat(updatedUser.captured.email).isEqualTo(user.email) },
{ assertThat(updatedUser.captured.preferences.darkMode).isFalse() },
{ assertThat(updatedUser.captured.preferences.locale).isEqualTo(Locale.GERMAN) },
{ assertThat(updatedUser.captured.preferences.currency).isEqualTo(Currency.getInstance("USD")) }
)
}
Finally, of course, the Postman suite needs to be adapted to contain also these changes. However, this is quite cumbersome to add in this post and has been covered before. If you’re unsure on how to set up assertions in Postman, or how to run the resulting integration test in your CI/CD pipeline, please refer to the following article.
There we have it - the concept of users in the application. In the next article, we will tie the concept of the user to the accounts, to enable users to have multiple accounts and have more personalised lookups. Stay tuned!