Config Cursors

When a ConfigReader needs to be created from scratch, users need to implement a from method with the following signature:

def from(cur: ConfigCursor): ConfigReader.Result[A]

The ConfigCursor class is a wrapper for the raw ConfigValue provided by Typesafe Config. It provides an idiomatic, typesafe API for the most common operations needed while reading a config. In particular, it provides cast operations and key accesses that integrate neatly with the PureConfig errors API. When using cursors properly, most errors are automatically handled and filled with rich information about the location of the failure.

We’ll show how to implement our own ConfigReader for the following class:

class Person(firstName: String, lastNames: Array[String]) {
  override def toString = s"Person($firstName ${lastNames.mkString(" ")})"
}

case class Conf(person: Person)

We intend our config to look like this:

import com.typesafe.config.ConfigFactory

val conf = ConfigFactory.parseString("person.name: John Doe")

For the purposes of this example, we’ll assume the provided name will always have at least two words.

An implementation of the ConfigReader using the cursor API is shown below:

import pureconfig._
import pureconfig.generic.auto._

def firstNameOf(name: String): String =
  name.takeWhile(_ != ' ')

def lastNamesOf(name: String): Array[String] =
  name.dropWhile(_ != ' ').drop(1).split(" ")

implicit val personReader = ConfigReader.fromCursor[Person] { cur =>
  for {
    objCur <- cur.asObjectCursor      // 1
    nameCur <- objCur.atKey("name")   // 2
    name <- nameCur.asString          // 3
  } yield new Person(firstNameOf(name), lastNamesOf(name))
}

The factory method ConfigReader.fromCursor allows us to create a ConfigReader without much boilerplate by providing the required ConfigCursor => ConfigReader.Result[A] function. Since most methods in the cursor API return Either values with failures at their left side, for comprehensions are a natural fit. Let’s analyze the lines marked above:

  1. asObjectCursor casts a cursor to a special ConfigObjectCursor, which contains methods exclusive to config objects. If the provided config value is not an object, the method returns a Left and the execution stops here;
  2. atKey is defined only on object cursors and accesses a given key on the underlying object. Once more, trying to access a non-existing key results in an error, stopping the for comprehension;
  3. having a cursor for the name key we want, asString tries to cast the config value pointed to by the cursor to a string.

You can use the fluent cursor API, an alternative interface focused on easy navigation over error handling, to achieve the same effect:

implicit val personReader = ConfigReader.fromCursor[Person] { cur =>
  cur.fluent.at("name").asString.map { name =>
    new Person(firstNameOf(name), lastNamesOf(name))
  }
}

Either way, a well-formed config will now work correctly:

ConfigSource.fromConfig(conf).load[Conf]
// res1: ConfigReader.Result[Conf] = Right(Conf(Person(John Doe)))

While malformed configs will fail to load with appropriate errors:

ConfigSource.string("person = 45").load[Conf]
// res2: ConfigReader.Result[Conf] = Left(
//   ConfigReaderFailures(
//     ConvertFailure(
//       WrongType(NUMBER, Set(OBJECT)),
//       Some(ConfigOrigin(String)),
//       "person"
//     ),
//     WrappedArray()
//   )
// )
ConfigSource.string("person.eman = John Doe").load[Conf]
// res3: ConfigReader.Result[Conf] = Left(
//   ConfigReaderFailures(
//     ConvertFailure(
//       KeyNotFound("name", Set()),
//       Some(ConfigOrigin(String)),
//       "person"
//     ),
//     WrappedArray()
//   )
// )
ConfigSource.string("person.name = [1, 2]").load[Conf]
// res4: ConfigReader.Result[Conf] = Left(
//   ConfigReaderFailures(
//     ConvertFailure(
//       WrongType(LIST, Set(STRING)),
//       Some(ConfigOrigin(String)),
//       "person.name"
//     ),
//     WrappedArray()
//   )
// )

By using the appropriate ConfigCursor methods, all error handling was taken care of by PureConfig. That makes PureConfig easy to use even when users have to deal with the low-level details of the conversions.