Overview

In this post I will show, how we leveraged PureConfig, and were able to use its Sealed Families feature in order to have multiple configuration options for the same resource.

Or more specifically, how we allowed to pass in our configuration, different types of authentication configurations when creating an RDS connection configuration.

What is Pureconfig

PureConfig is Github’s library for basically converting configuration files into Scala Classes, more specifically and common, into case classes.

It is pretty simple to load a Typesafe supported configuration file into a case class using that library, as simple as the following configuration and code:

"db-conn": {
    "host": "localhost"
    "port": 5432
    "user": "admin"
    "password": "admin"
}
import pureconfig._
import pureconfig.generic.auto._

object Main extends App {
  case class DBConn(host: String, port: Int, user: String, password: String)
  case class ServiceConf(dbConn: DBConn)

  val conf = ConfigSource.default.load[ServiceConf]

  println(conf) // Right(ServiceConf(DBConn(localhost,5432,admin,admin)))
}

As we can see, it is pretty straightforward. We define a couple of case classes that describe the configuration file, and ask pureconfig to create an instance of that class filled with the correct values.

What is a Sealed Family

A Sealed Family allows us to create a sealed family of case classes which allows us to create different types of configuration options, depending on the type that we specify.

For example if we have different authentication options for our database connection, one option is to connect by user/password and the other one is to use user/IAM Token (for AWS RDS as an example), we can define them using a sealed trait, and some case classes.

We would see that exact example in the following sections.

Why do we use it

In our case as I have already mentioned, we rely on that feature for our connection to a Database. As we are using the AWS RDS service, we have also enabled there the IAM authentication method. Which means that the password for connecting to the Database is generated using an AWS RDSIamAuthTokenGenerator. For that generator some IAM related configurations are needed. An example of how to generate a real token you can find in my previous post.

But of course there are cases where we want to check our service locally, and so we would want to connect using a simple user/password option.

A sample configuration with IAM (./resources/db-iam.conf):

"db-conn": {
    "host": "localhost"
    "port": 5432
    "user": "admin"
    "auth": {
        "type": "iam"
        "iam": {
            "region": "us-west-2"
            "assumed-role": "arn:aws:iam::9999999999:role/some-role"
            "assume-role-session-name": "my-session"
        }
    }
}

A sample configuration with simple password (./resources/db-password.conf):

"db-conn": {
    "host": "localhost"
    "port": 5432
    "user": "admin"
    "auth": {
        "type": "password"
        "value": "admin"
    }
}

As we can see those configurations should create two different case classes. The type attribute, will tell PureConfig which case class to use. The next section will contain a working example with some explanations.

Implementation

The first basic configuration is needed for the IAM configuration that was mentioned in the previous section:

case class IamConf(region: String, assumedRole: String, assumeRoleSessionName: String)

We would like to allow the previously mentioned DBConn case class to handle two types of authentications. The IAM one, and a simple password one.

For that we will create a sealed trait that will combine both of the options:

sealed trait DBAuth
case class password(value: String) extends DBAuth
case class iam(iam: IamConf) extends DBAuth

We will call that trait DBAuth and extend the options (case classes) with that trait.

The simple password option, will just contain the password itself.

The iam option, will contain all the relevant fields that were mentioned in the IamConf case class.

And now the DBConn case class would look like this:

case class DBConn(host: String, port: Int, user: String, auth: DBAuth)

We can notice that there is an auth field, that is of type of the DBAuth trait. And it will accept correctly the type of the auth that is specified in the configuration file.

The ServiceConf case class stays the same:

case class ServiceConf(dbConn: DBConn)

So now, if we load the configuration files for each of the types we would get the following results:

ConfigSource.file("./resources/db-password.conf").load[ServiceConf]
// Right(ServiceConf(DBConn(localhost,5432,admin,password(admin))))

ConfigSource.file("./resources/db-iam.conf").load[ServiceConf]
// Right(ServiceConf(DBConn(localhost,5432,admin,iam(IamConf(us-west-2,arn:aws:iam::9999999999:role/some-role,my-session)))))

All is good, but there is a little problem. If we want to access the auth fields, we are unable to do it easily as the DBAuth trait doesn’t contain any common fields with the extending case classes.

Which means, that this code won’t work:

configPass.map(_.dbConn.auth.password)

Because DBAuth doesn’t have a password attribute.

Common attribute

Basically what we tried to achieve with the DBAuth configuration. Is to have a single place for having the password that is needed for the connection. It can come from different configuration types, but eventually it should return for us a password.

So basically we have a common attribute for both implementations, and it is the password attribute.

For the simple password configuration type, we will just return the password that is written in the configuration.

But, for the iam configuration type, we would want to use the IAM attributes to generate a token that we would use as the password.

Because we would need to generate the token whenever we call the password attribute, we would want to make that attribute a function. So we would add a method that is called password to the DBAuth trait, and implement it in each of the subtypes:

sealed trait DBAuth {
    def password: String
}

case class password(value: String) extends DBAuth {
    override def password: String = value
}
case class iam(iam: IamConf) extends DBAuth {
    override def password: String = TokenGenerator(iam).getToken
}

// Just a dummy class for the token generation feature.
case class TokenGenerator(iam: IamConf) {
    def getToken: String = "some token"
}

Now it is possible to call the following code, in order to get the appropriate password:

configPass.map(_.dbConn.auth.password)
// Right(admin)

configIam.map(_.dbConn.auth.password)
// Right(some token)

More Beautification

Currently if we would like to access the user and password attributes, it would look a bit finicky, at least to me. Let’s look at the following example:

val confPass = configPass.getOrElse(throw new RuntimeException) // Extract the configuration object or throw an exception.
confPass.dbConn.user
confPass.dbConn.auth.password

We can see that the path to the user and to the password, is a bit different. I would prefer to have the password on the same level as the user. The solution is pretty easy, we can add a method to the DBConn that would shorten the path for us. Like this:

case class DBConn(host: String, port: Int, user: String, auth: DBAuth) {
    def password: String = auth.password
}

And now it is possible to call the password attribute on the same level as the user attribute.

val confPass = configPass.getOrElse(throw new RuntimeException) // Extract the configuration object or throw an exception.
confPass.dbConn.user
confPass.dbConn.password

And it will call the appropriate password method of the appropriate DBAuth implementation.

Hope this post was helpful, thank you for reading.