Building a Scalable Backend for GameShop with Kotlin
If you don’t know what KTOR is, I strongly recommend checking my last post: Ktor as a backend that can be built in a blink of an eye. Where I will guide you through the project setup and building your first API. In this blog post, I will use a lot of code from the mentioned project and just add it to the different modules.
Let’s dive into our work.
Firstly, we should add some simple routing and check if everything works. As we are building a GameShop, the most critical endpoint should allow us to fetch all available games. This requires us to configure the proper endpoint.
Application.kt
fun Application.module() {
configureRouting()
}
Routing.kt
fun Application.configureRouting() {
install(Routing)
routing {
gamesRouting()
}
}
GamesRoutes.kt
fun Route.gamesRouting() {
route("/games") {
get {
call.respondText(text = "There are no games in our shop yet...", status=HttpStatusCode.OK)
}
}
}
We have the simplest possible routing, the server is working and it’s providing some fake response.
Now we need to think about how to utilize as much as possible from the KMM approach and be able to share as much code with the backend and the frontend applications. In general, our goal at this point is to use the same domain models and, the same repositories (but different implementations). I will create the domain and the repository as separate modules.
The shared/commonMain module will be used for frontends. It will implement the domain and the repository. This module will also have one common http client for all platforms.
The server module will implement the domain and repository. It will use interfaces from the repository but with different implementations than on the frontends.
Game.kt
data class Game(
val name: String
)
GamesRepository.kt
interface GamesRepository {
suspend fun getGames(): List<Game>
}
The backend will implement GamesRepository as DatabaseGamesRepository with H2 Database and the apps will implement this repo as HttpGamesRepository and will use the KTOR client to communicate with the server. In this approach, we can achieve good code separation and a lot of common code that can be widely used in the application.
We can start from creating the RealDatabaseGamesRepository it implements common interface but uses the database through the GamesDAOFacade.
internal class RealDatabaseGamesRepository(
private val dao: GamesDAOFacade
) : GamesRepository {
override suspend fun getGames(): List<Game> = dao.getGames()
}
If you want to know more about DI and Database in KTOR the full description is here.
We can now inject the repository into the routing and fetch with HTTP requests.
fun Route.gamesRouting() {
val repo by inject<GamesRepository>()
get<GamesResources> {
val games = repo.getGames()
call.respond(
status = HttpStatusCode.OK,
message = games,
)
}
}
Following up on the KTOR blog post we will add:
- JWT authentication
- Administrator role
- Login functionality
- Game management endpoints
- Orders endpoint
These will not be covered in this article as they be can found in the mentioned link.
To make this application a bit more interesting we can add ROLES to users and then on the frontends show or hide some functionalities. For example, users will only be able to fetch games but the administrator will be able to manage them. With such an approach we can create an MVP for content management. To do so we need to implement roles, we can start from the shared domain object, the roles table, and a relation between users and roles.
domain/Role.kt
@Serializable
enum class Role {
ADMIN,
USER,
}
domain/User.kt
@Serializable
data class User(
val id: String,
val name: String,
val username: String,
val role: Role,
)
@Serializable
data class UserRequest(
val name: String,
val username: String,
val password: String,
)
server/Roles.kt
object Roles : IntIdTable() {
val name = varchar("name", 128)
}
server/Users.kt
object Users : UUIDTable() {
...
val role = reference("role_id", Roles, onDelete = ReferenceOption.CASCADE).index()
}
The Users table was extended with the role property that will add a relation to the roles table. We need to follow the rules from Exposed about creating DAO therefore we need to do a refactor of existing classes and repositories. For proper relation mapping, we should use Entities that will reflect the real row in a Database, you can read more about it here.
Roles.kt
class RoleEntity(id: EntityID<Int>) : IntEntity(id) {
companion object : IntEntityClass<RoleEntity>(Roles)
var name by Roles.name
}
Users.kt
class UserEntity(id: EntityID<UUID>) : UUIDEntity(id) {
companion object : UUIDEntityClass<UserEntity>(Users)
var name by Users.name
var date_created by Users.date_created
var username by Users.username
var password by Users.password
var role by RoleEntity referencedOn Users.role
}
Since we will now operate on the entities the DAOFacade classes need to be adjusted. We will not directly call the tables but perform all operations through the newly introduced entity object.
RealUsersDAOFacade.kt
override suspend fun createUser(userRequest: UserRequest) = dbQuery {
val userRoleId = requireNotNull(rolesDAOFacade.getIdByRole(Role.USER))
UserEntity.new {
name = userRequest.name
username = userRequest.username
password = userRequest.password
date_created = Clock.System.now().toLocalDateTime(TimeZone.UTC).date.toString()
role = RoleEntity[userRoleId]
}.toUser()
}
override suspend fun getUsers(): List<User> = dbQuery {
UserEntity.all().map { it.toUser() }
}
Once roles are implemented, the last thing that needs to be done is to secure our routes. With the JWT we ensured that our user is authenticated and the roles allow us to authorize the calls. Will add the information about the role to the JWT token. In this case, we can easily create our own KTOR plugin (if you want to read more about custom plugins, please check the docs). The plugin will parse every token and extract the user’s role – then if the role is the same as requested by the route it will allow the user to perform the call. If not it will throw an exception that will be handled by the status pages plugin.
Adding role to the JWT is straightforward, we will add it in the same place as the userId.
val token = JWT.create()
…
.withClaim("userId", it.id)
.withClaim("role", listOf(it.role.name))
…
The plugin will rely on an authentication hook – this means that code inside the hook will be called after authentication. From the authenticated call we can easily obtain the decrypted JTW payload. From the payload, we can extract the value of the role property.
Then we will create the extension function for the Route class, this extension function will be invoked with every endpoint call, and the function will be called with a set of roles that can access a given endpoint. The function will then install the authorization plugin and pass the required roles.
One more thing is the plugin configuration, the configuration is required to pass the roles inside the plugin.
In the end, we can create a custom exception that will be thrown on any error.
class AuthorizationException(
route: String,
val reasons: List<String>,
) : IllegalArgumentException("You don`t have access to $route. Reasons: ${reasons.joinToString()}")
internal class AuthorizationConfiguration {
val requiredRoles: MutableSet<Role> = mutableSetOf()
fun roles(roles: Set<Role>) {
requiredRoles.addAll(roles)
}
}
internal val RoleAuthorizationPlugin =
createRouteScopedPlugin("RoleAuthorizationPlugin", ::AuthorizationConfiguration) {
on(AuthenticationChecked) { call ->
val principal = call.principal<JWTPrincipal>() ?: return@on
val roles =
principal
.payload
.getClaim("role")
.asList(String::class.java)
.map { Role.valueOf(it) }
if (pluginConfig.requiredRoles.isNotEmpty() && roles.intersect(pluginConfig.requiredRoles).isEmpty()) {
throw AuthorizationException(
route = call.request.path(),
reasons = listOf("You don`t have required role"),
)
}
}
}
fun Route.withRole(
vararg roles: Role,
build: Route.() -> Unit,
) {
val route =
createChild(
object : RouteSelector() {
override fun evaluate(
context: RoutingResolveContext,
segmentIndex: Int,
): RouteSelectorEvaluation {
// Transparent selector means that it does not consume anything from the URL, so child routes can match.
return RouteSelectorEvaluation.Transparent
}
},
)
route.install(RoleAuthorizationPlugin) {
roles(roles.toSet())
}
route.build()
}
There is nothing more left but to use the freshly created plugin on our routes, lets update the users routing.
fun Route.usersRouting() {
val repo by inject<DatabaseUsersRepository>()
authenticate("jwt-auth") {
withRole(Role.ADMIN) {
route(“/users”) {
}
}
}
With everything wired up we can test it with HTTP requests from LoginRequests.http if we try to ask for users from an account different than admin we should see the proper message:
GET http://0.0.0.0:8080/users
HTTP/1.1 401 Unauthorized
You don`t have required role
The last thing that needs to be done is returning the role to the clients. We can achieve that by adding the user object to the login response. When we are creating the login response, we already have the user fetched from the DB – for checking if such a client exists and providing its ID as a part of the JWT claim. All we need to do is use this property and add it to the LoginResponse object.
@Serializable
data class LoginResponse(
@SerialName("token")
val token: String,
@SerialName("user")
val user: User,
)
Our backend is up and running with basic functionalities now we need to switch our focus to the front-end applications. In the next blog post, I will add the KTOR client to the shared module that can be used by front-end applications (Android, iOS, and Web). Stay tuned!