Supporting New Sources

Ciris already has support for common sources in the core module, while external libraries provide additional configuration sources. However, it’s also easy to create your own configuration sources, and Ciris provides many helper functions in the companion object of ConfigSource for that purpose. Following, we’ll show how we can create a simple configuration source for reading property files. While property files generally shouldn’t be necessary when using configurations as code, they can definitely be supported with Ciris when necessary.

We start by defining a ConfigSource for reading property files as Map[String, String]s. We could reuse the existing ConfigSource.File for reading files, but we would rather avoid having to create an intermediate String representation, so we’ll instead define our own ConfigSource for property files.

import ciris.{ConfigKeyType, ConfigSource}
import ciris.api.Id
import java.io.{File, FileInputStream, InputStreamReader}
import java.nio.charset.Charset
import java.util.Properties
import scala.collection.JavaConverters._
import scala.util.Try

val propFileSource: ConfigSource[Id, (File, Charset), Map[String, String]] =
  ConfigSource.catchNonFatal(ConfigKeyType.File) {
    case (file, charset) =>
      val fis = new FileInputStream(file)
      try {
        val isr = new InputStreamReader(fis, charset)
        val props = new Properties
        props.load(isr)
        props.asScala.toMap
      } finally {
        val _ = Try(fis.close())
      }
  }

The ConfigSource is using the existing ConfigKeyType.File, which uses (File, Charset) as the key type. The source also makes use of ConfigSource.catchNonFatal to catch any exceptions when reading the properties file. Finally, the properties are converted to a Map, and the FileInputStream is closed, ignoring any closing exceptions.

If you’re creating a custom ConfigSource by directly extending ConfigSource, rather than by using any of the helper functions in the companion object, you need to make sure you provide appropriate ConfigErrors. In particular, you should return missingKey if a key is not available from the source. This error is used by various functions, like orElse and orNone, to fall back to other values if the previous keys have not been set.

The PropFileKey case class fully identifies a property file key. It is a combination of the File, Charset, and String key which we are retrieving. The toString function has been overridden to provide the String representation we would like in error messages. We’ll also describe the name and type of the key by creating a ConfigKeyType.

final case class PropFileKey(
  file: File,
  charset: Charset,
  key: String
) {
  override def toString: String =
    s"file = $file, charset = $charset, key = $key"
}

val propFileKeyType: ConfigKeyType[PropFileKey] =
  ConfigKeyType("property file key")

Since we would like to avoid having to read the file contents multiple times when reading more than one key, we define a helper class PropFileAt, which partially applies PropFileKey on File and Charset, reading the file once for multiple keys. The class defines an apply function for reading and decoding keys, similar to env, prop, and file included in the core module.

import ciris.{ConfigEntry, ConfigError, ConfigDecoder}

final class PropFileAt(file: File, charset: Charset) {
  private val propFile: Either[ConfigError, Map[String, String]] =
    propFileSource
      .read((file, charset))
      .value

  private def propFileKey(key: String): PropFileKey =
    PropFileKey(file, charset, key)

  private def propFileAt(key: String): Either[ConfigError, String] =
    propFile.flatMap { props =>
      props.get(key).toRight {
        ConfigError.missingKey(
          propFileKey(key),
          propFileKeyType
        )
      }
    }

  def apply[Value](key: String)(
    implicit decoder: ConfigDecoder[String, Value]
  ): ConfigEntry[Id, PropFileKey, String, Value] = {
    ConfigEntry(
      propFileKey(key),
      propFileKeyType,
      propFileAt(key)
    ).decodeValue[Value]
  }

  override def toString: String =
    s"PropFileAt($file, $charset)"
}

Finally, we define propFileAt as a convenience function for creating instances of PropFileAt.

def propFileAt(
  name: String,
  charset: Charset = Charset.defaultCharset
): PropFileAt = {
  new PropFileAt(new File(name), charset)
}

We can then use the newly defined property file source as follows.

import eu.timepit.refined.types.net.UserPortNumber
// import eu.timepit.refined.types.net.UserPortNumber

import ciris.refined._
// import ciris.refined._

val propFile = propFileAt(tempFileName)
// propFile: PropFileAt = PropFileAt(/var/folders/ff/tg7g7zh52_g9s5_djb5z81w00000gn/T/temp-2008581062645448130.properties, UTF-8)

propFile[UserPortNumber]("port")
// res0: ciris.ConfigEntry[ciris.api.Id,PropFileKey,String,eu.timepit.refined.types.net.UserPortNumber] = ConfigEntry(file = /var/folders/ff/tg7g7zh52_g9s5_djb5z81w00000gn/T/temp-2008581062645448130.properties, charset = UTF-8, key = port, ConfigKeyType(property file key))

Suspending Effects

Since reading the property file contents isn’t pure, we could make use of effect types to suspend the reading of the file. We start by extracting the reading of the property file to outside of PropFileAt, so it doesn’t have to deal with effects explicitly. PropFileAt now simply accepts the property file as an argument with context F. We require that there is a Monad instance available for F, since the ConfigDecoder requires it for decodeValue.

import ciris.api.Monad
import ciris.api.syntax._

final class PropFileAt[F[_]: Monad](
  file: File,
  charset: Charset,
  propFile: F[Either[ConfigError, Map[String, String]]]
) {
  private def propFileKey(key: String): PropFileKey =
    PropFileKey(file, charset, key)

  private def propFileAt(key: String): F[Either[ConfigError, String]] =
    propFile.map { errorOrProps =>
      errorOrProps.flatMap { props =>
        props.get(key).toRight {
          ConfigError.missingKey(
            propFileKey(key),
            propFileKeyType
          )
        }
      }
    }

  def apply[Value](key: String)(
    implicit decoder: ConfigDecoder[String, Value]
  ): ConfigEntry[F, PropFileKey, String, Value] = {
    ConfigEntry
      .applyF(
        propFileKey(key),
        propFileKeyType,
        propFileAt(key)
      )
      .decodeValue[Value]
  }

  override def toString: String =
    s"PropFileAt($file, $charset, $propFile)"
}

With the property file reading extracted, we can now define propFileAtF, which suspends the reading of the property file into context F. We would also like that the file is not read more than once, so we also need to memoize the result. The cats-effect module provides a suspendMemoizeF function on ConfigSource with a syntax import, which creates a ConfigSource with both suspended reading and memoized results. The function works on any context F for which there is a Concurrent instance defined.

import cats.effect.Concurrent
import ciris.cats.effect._
import ciris.cats.effect.syntax._

def propFileAtF[F[_]: Concurrent](
  name: String,
  charset: Charset = Charset.defaultCharset
): F[PropFileAt[F]] = {
  val file = new File(name)

  val propFile =
    propFileSource
      .suspendMemoizeF[F]
      .read((file, charset))
      .value

  propFile.map(new PropFileAt(file, charset, _))
}

We can then use propFileAtF to read property file keys as follows.

import cats.effect.{ContextShift, IO}
// import cats.effect.{ContextShift, IO}

implicit val contextShift: ContextShift[IO] =
  IO.contextShift(concurrent.ExecutionContext.global)
// contextShift: cats.effect.ContextShift[cats.effect.IO] = [email protected]

val propFileF = propFileAtF[IO](tempFileName)
// propFileF: cats.effect.IO[PropFileAt[cats.effect.IO]] = <function1>

for {
  propFile <- propFileF
  port = propFile[UserPortNumber]("port")
} yield port
// res1: cats.effect.IO[ciris.ConfigEntry[cats.effect.IO,PropFileKey,String,eu.timepit.refined.types.net.UserPortNumber]] = <function1>