From b827fcd602f7318f3041b8b3c2f499633df92d29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20L=C3=B6vgren?= Date: Fri, 12 May 2017 14:49:01 +0100 Subject: [PATCH] Add ciris-generic module using shapeless --- build.sbt | 33 ++++++- docs/src/main/tut/index.md | 4 + .../src/main/scala/ciris/ConfigError.scala | 18 ++++ .../src/main/scala/ciris/ConfigReader.scala | 5 + .../test/scala/ciris/ConfigErrorsSpec.scala | 13 ++- .../src/test/scala/ciris/PropertySpec.scala | 3 + .../readers/EnumeratumConfigReadersSpec.scala | 54 +++++++++-- .../main/scala/ciris/generic/package.scala | 5 + .../readers/GenericConfigReaders.scala | 41 ++++++++ .../readers/GenericConfigReadersSpec.scala | 97 +++++++++++++++++++ 10 files changed, 258 insertions(+), 15 deletions(-) create mode 100644 modules/generic/jvm/src/main/scala/ciris/generic/package.scala create mode 100644 modules/generic/jvm/src/main/scala/ciris/generic/readers/GenericConfigReaders.scala create mode 100644 modules/generic/jvm/src/test/scala/ciris/generic/readers/GenericConfigReadersSpec.scala diff --git a/build.sbt b/build.sbt index 7f8ff3ec..ee408218 100644 --- a/build.sbt +++ b/build.sbt @@ -12,6 +12,7 @@ lazy val ciris = project .aggregate( coreJS, coreJVM, enumeratumJS, enumeratumJVM, + genericJS, genericJVM, refinedJS, refinedJVM, squantsJS, squantsJVM ) @@ -38,6 +39,24 @@ lazy val enumeratum = crossProject lazy val enumeratumJS = enumeratum.js lazy val enumeratumJVM = enumeratum.jvm +lazy val generic = crossProject + .in(file("modules/generic")) + .settings(moduleName := "ciris-generic", name := "Ciris generic") + .settings( + libraryDependencies ++= + Seq( + "com.chuusai" %%% "shapeless" % "2.3.2", + compilerPlugin("org.scalamacros" % "paradise" % "2.1.0" % Test cross CrossVersion.patch) + ) + ) + .settings(scalaSettings) + .settings(releaseSettings) + .settings(testSettings) + .dependsOn(core % "compile;test->test") + +lazy val genericJS = generic.js +lazy val genericJVM = generic.jvm + lazy val refined = crossProject .in(file("modules/refined")) .settings(moduleName := "ciris-refined", name := "Ciris refined") @@ -87,11 +106,12 @@ lazy val docs = project crossScalaVersions, BuildInfoKey.map(moduleName in coreJVM) { case (k, v) ⇒ "core" + k.capitalize -> v }, BuildInfoKey.map(moduleName in enumeratumJVM) { case (k, v) ⇒ "enumeratum" + k.capitalize -> v }, + BuildInfoKey.map(moduleName in genericJVM) { case (k, v) ⇒ "generic" + k.capitalize -> v }, BuildInfoKey.map(moduleName in refinedJVM) { case (k, v) ⇒ "refined" + k.capitalize -> v }, BuildInfoKey.map(moduleName in squantsJVM) { case (k, v) ⇒ "squants" + k.capitalize -> v } ) ) - .dependsOn(coreJVM, enumeratumJVM, refinedJVM, squantsJVM) + .dependsOn(coreJVM, enumeratumJVM, genericJVM, refinedJVM, squantsJVM) .enablePlugins(BuildInfoPlugin, MicrositesPlugin) lazy val scala210 = "2.10.6" @@ -118,9 +138,11 @@ lazy val scalaSettings = Seq( "-Ywarn-numeric-widen", "-Ywarn-value-discard", "-Xfuture", - "-Ywarn-unused-import" + "-Ywarn-unused-import", + "-Ywarn-unused" ).filter { case "-Ywarn-unused-import" if scalaVersion.value == scala210 ⇒ false + case "-Ywarn-unused" if scalaVersion.value != scala212 ⇒ false case _ ⇒ true }, scalacOptions in (Compile, console) -= "-Ywarn-unused-import", @@ -257,9 +279,10 @@ generateScripts in ThisBuild := { | com.lihaoyi:ammonite_2.12.2:0.8.4 \\ | $organizationId:${(moduleName in coreJVM).value}_2.12:$moduleVersion \\ | $organizationId:${(moduleName in enumeratumJVM).value}_2.12:$moduleVersion \\ + | $organizationId:${(moduleName in genericJVM).value}_2.12:$moduleVersion \\ | $organizationId:${(moduleName in refinedJVM).value}_2.12:$moduleVersion \\ | $organizationId:${(moduleName in squantsJVM).value}_2.12:$moduleVersion \\ - | -- --predef 'import ciris._,ciris.enumeratum._,ciris.refined._,ciris.squants._' < /dev/tty + | -- --predef 'import ciris._,ciris.enumeratum._,ciris.generic._,ciris.refined._,ciris.squants._' < /dev/tty """.stripMargin.trim + "\n" IO.createDirectory(output) @@ -275,8 +298,8 @@ updateScripts in ThisBuild := { } } -lazy val allModules = List("core", "enumeratum", "refined", "squants") -lazy val allModulesJS = allModules.map(_ + "JS") +lazy val allModules = List("core", "enumeratum", "generic", "refined", "squants") +lazy val allModulesJS = allModules.filterNot(Set("generic")).map(_ + "JS") lazy val allModulesJVM = allModules.map(_ + "JVM") def addCommandsAlias(name: String, values: List[String]) = diff --git a/docs/src/main/tut/index.md b/docs/src/main/tut/index.md index 8b5f8ae4..e162562b 100644 --- a/docs/src/main/tut/index.md +++ b/docs/src/main/tut/index.md @@ -34,6 +34,7 @@ s""" |libraryDependencies ++= Seq( | "$organization" %% "$coreModuleName" % "$latestVersion", | "$organization" %% "$enumeratumModuleName" % "$latestVersion", + | "$organization" %% "$genericModuleName" % "$latestVersion", | "$organization" %% "$refinedModuleName" % "$latestVersion", | "$organization" %% "$squantsModuleName" % "$latestVersion" |) @@ -46,6 +47,7 @@ and make sure to replace `%%` with `%%%` if you are using Scala.js. The only required module is `ciris-core`, the rest are optional library integrations. - The `ciris-enumeratum` module allows loading [enumeratum][enumeratum] enumerations. +- The `ciris-generic` module allows loading more types with [shapeless][shapeless]. - The `ciris-refined` module allows loading [refined][refined] refinement types. - The `ciris-squants` module allows loading [squants][squants] data types. @@ -65,6 +67,7 @@ println( s""" |import $$ivy.`$organization::$coreModuleName:$latestVersion`, ciris._ |import $$ivy.`$organization::$enumeratumModuleName:$latestVersion`, ciris.enumeratum._ + |import $$ivy.`$organization::$genericModuleName:$latestVersion`, ciris.generic._ |import $$ivy.`$organization::$refinedModuleName:$latestVersion`, ciris.refined._ |import $$ivy.`$organization::$squantsModuleName:$latestVersion`, ciris.squants._ """.stripMargin.trim @@ -211,6 +214,7 @@ withValue(env[Option[AppEnvironment]]("APP_ENV")) { [enumeratum]: https://github.com/lloydmeta/enumeratum [refined]: https://github.com/fthomas/refined +[shapeless]: https://github.com/milessabin/shapeless [squants]: http://www.squants.com [sbt]: http://www.scala-sbt.org [scala]: http://www.scala-lang.org diff --git a/modules/core/shared/src/main/scala/ciris/ConfigError.scala b/modules/core/shared/src/main/scala/ciris/ConfigError.scala index 6bca4cb6..f93c6785 100644 --- a/modules/core/shared/src/main/scala/ciris/ConfigError.scala +++ b/modules/core/shared/src/main/scala/ciris/ConfigError.scala @@ -3,11 +3,29 @@ package ciris sealed abstract class ConfigError { def message: String + final def combine(error: ConfigError): ConfigError = + ConfigError.combined(this, error) + final def append(error: ConfigError): ConfigErrors = ConfigErrors(this) append error } object ConfigError { + def apply(message: String): ConfigError = { + val theMessage = message + new ConfigError { + override val message: String = theMessage + override def toString: String = s"ConfigError($message)" + } + } + + sealed abstract case class Combined(errors: Vector[ConfigError]) extends ConfigError { + override def message: String = errors.map(_.message).mkString(", ") + } + + def combined(error1: ConfigError, error2: ConfigError, rest: ConfigError*): Combined = + new Combined(Vector(error1, error2) ++ rest) {} + final case class MissingKey(key: String, keyType: ConfigKeyType) extends ConfigError { override def message: String = s"Missing ${keyType.value} [$key]" } diff --git a/modules/core/shared/src/main/scala/ciris/ConfigReader.scala b/modules/core/shared/src/main/scala/ciris/ConfigReader.scala index fa946f3d..b6e37068 100644 --- a/modules/core/shared/src/main/scala/ciris/ConfigReader.scala +++ b/modules/core/shared/src/main/scala/ciris/ConfigReader.scala @@ -8,6 +8,11 @@ import scala.util.{Failure, Success, Try} sealed abstract class ConfigReader[A] { self ⇒ def read(key: String)(implicit source: ConfigSource): Either[ConfigError, A] + final def map[B](f: A ⇒ B): ConfigReader[B] = + ConfigReader.pure { (key, source) ⇒ + self.read(key)(source).fold(Left.apply, value ⇒ Right(f(value))) + } + final def mapOption[B](typeName: String)(f: A ⇒ Option[B]): ConfigReader[B] = ConfigReader.pure { (key, source) ⇒ self diff --git a/modules/core/shared/src/test/scala/ciris/ConfigErrorsSpec.scala b/modules/core/shared/src/test/scala/ciris/ConfigErrorsSpec.scala index c5322b68..4ef42e8e 100644 --- a/modules/core/shared/src/test/scala/ciris/ConfigErrorsSpec.scala +++ b/modules/core/shared/src/test/scala/ciris/ConfigErrorsSpec.scala @@ -6,8 +6,11 @@ final class ConfigErrorsSpec extends PropertySpec { "ConfigErrors" when { "converting to String" should { "list the errors" in { - val configErrors = ConfigErrors(MissingKey("key", ConfigKeyType.Environment)) - configErrors.toString shouldBe "ConfigErrors(MissingKey(key,Environment))" + val configErrors = + ConfigErrors(MissingKey("key", ConfigKeyType.Environment)) + .append(ConfigError("a")) + + configErrors.toString shouldBe "ConfigErrors(MissingKey(key,Environment),ConfigError(a))" } } @@ -17,11 +20,15 @@ final class ConfigErrorsSpec extends PropertySpec { ConfigErrors(MissingKey("key", ConfigKeyType.Environment)) .append(ReadException("key2", ConfigKeyType.Properties, new Error("error"))) .append(WrongType("key3", "value3", "Int", ConfigKeyType.Environment, cause = None)) + .append(ConfigError("a")) + .append(ConfigError.combined(ConfigError("b"), ConfigError("c"))) configErrors.messages shouldBe Vector( "Missing environment variable [key]", "Exception while reading system property [key2]: java.lang.Error: error", - "Environment variable [key3] with value [value3] cannot be converted to type [Int]" + "Environment variable [key3] with value [value3] cannot be converted to type [Int]", + "a", + "b, c" ) } } diff --git a/modules/core/shared/src/test/scala/ciris/PropertySpec.scala b/modules/core/shared/src/test/scala/ciris/PropertySpec.scala index cf506288..6d573fa8 100644 --- a/modules/core/shared/src/test/scala/ciris/PropertySpec.scala +++ b/modules/core/shared/src/test/scala/ciris/PropertySpec.scala @@ -7,6 +7,9 @@ import org.scalatest.{Matchers, WordSpec} import scala.util.Try class PropertySpec extends WordSpec with Matchers with PropertyChecks { + override implicit val generatorDrivenConfig: PropertyCheckConfiguration = + PropertyCheckConfiguration(minSuccessful = 1000) + def mixedCase(string: String): Gen[String] = { (for { lowers ← Gen.listOfN(string.length, Gen.oneOf(true, false)) diff --git a/modules/enumeratum/shared/src/test/scala/ciris/enumeratum/readers/EnumeratumConfigReadersSpec.scala b/modules/enumeratum/shared/src/test/scala/ciris/enumeratum/readers/EnumeratumConfigReadersSpec.scala index a1544d67..ef512344 100644 --- a/modules/enumeratum/shared/src/test/scala/ciris/enumeratum/readers/EnumeratumConfigReadersSpec.scala +++ b/modules/enumeratum/shared/src/test/scala/ciris/enumeratum/readers/EnumeratumConfigReadersSpec.scala @@ -87,8 +87,16 @@ final class EnumeratumConfigReadersSpec extends PropertySpec { } "return a failure for other values" in { + forAll { byte: Byte ⇒ + whenever(ByteEnumItem.withValueOpt(byte).isEmpty) { + readValue[ByteEnumItem](byte.toString) shouldBe a[Left[_, _]] + } + } + } + + "return a failure for wrong type values" in { forAll { string: String ⇒ - whenever(!ByteEnumItem.values.map(_.value.toString).contains(string)) { + whenever(fails(string.toByte)) { readValue[ByteEnumItem](string) shouldBe a[Left[_, _]] } } @@ -109,8 +117,16 @@ final class EnumeratumConfigReadersSpec extends PropertySpec { } "return a failure for other values" in { + forAll { char: Char ⇒ + whenever(CharEnumItem.withValueOpt(char).isEmpty) { + readValue[CharEnumItem](char.toString) shouldBe a[Left[_, _]] + } + } + } + + "return a failure for wrong type values" in { forAll { string: String ⇒ - whenever(!CharEnumItem.values.map(_.value.toString).contains(string)) { + whenever(string.length != 1) { readValue[CharEnumItem](string) shouldBe a[Left[_, _]] } } @@ -132,7 +148,7 @@ final class EnumeratumConfigReadersSpec extends PropertySpec { "return a failure for other values" in { forAll { string: String ⇒ - whenever(!EnumEntryItem.values.map(_.entryName).contains(string)) { + whenever(EnumEntryItem.withNameOption(string).isEmpty) { readValue[EnumEntryItem](string) shouldBe a[Left[_, _]] } } @@ -153,8 +169,16 @@ final class EnumeratumConfigReadersSpec extends PropertySpec { } "return a failure for other values" in { + forAll { int: Int ⇒ + whenever(IntEnumItem.withValueOpt(int).isEmpty) { + readValue[IntEnumItem](int.toString) shouldBe a[Left[_, _]] + } + } + } + + "return a failure for wrong type values" in { forAll { string: String ⇒ - whenever(!IntEnumItem.values.map(_.value.toString).contains(string)) { + whenever(fails(string.toInt)) { readValue[IntEnumItem](string) shouldBe a[Left[_, _]] } } @@ -175,8 +199,16 @@ final class EnumeratumConfigReadersSpec extends PropertySpec { } "return a failure for other values" in { + forAll { long: Long ⇒ + whenever(LongEnumItem.withValueOpt(long).isEmpty) { + readValue[LongEnumItem](long.toString) shouldBe a[Left[_, _]] + } + } + } + + "return a failure for wrong type values" in { forAll { string: String ⇒ - whenever(!LongEnumItem.values.map(_.value.toString).contains(string)) { + whenever(fails(string.toLong)) { readValue[LongEnumItem](string) shouldBe a[Left[_, _]] } } @@ -197,8 +229,16 @@ final class EnumeratumConfigReadersSpec extends PropertySpec { } "return a failure for other values" in { + forAll { short: Short ⇒ + whenever(ShortEnumItem.withValueOpt(short).isEmpty) { + readValue[ShortEnumItem](short.toString) shouldBe a[Left[_, _]] + } + } + } + + "return a failure for wrong type values" in { forAll { string: String ⇒ - whenever(!ShortEnumItem.values.map(_.value.toString).contains(string)) { + whenever(fails(string.toShort)) { readValue[ShortEnumItem](string) shouldBe a[Left[_, _]] } } @@ -220,7 +260,7 @@ final class EnumeratumConfigReadersSpec extends PropertySpec { "return a failure for other values" in { forAll { string: String ⇒ - whenever(!StringEnumItem.values.map(_.value.toString).contains(string)) { + whenever(StringEnumItem.withValueOpt(string).isEmpty) { readValue[StringEnumItem](string) shouldBe a[Left[_, _]] } } diff --git a/modules/generic/jvm/src/main/scala/ciris/generic/package.scala b/modules/generic/jvm/src/main/scala/ciris/generic/package.scala new file mode 100644 index 00000000..9003faac --- /dev/null +++ b/modules/generic/jvm/src/main/scala/ciris/generic/package.scala @@ -0,0 +1,5 @@ +package ciris + +import ciris.generic.readers.GenericConfigReaders + +package object generic extends GenericConfigReaders diff --git a/modules/generic/jvm/src/main/scala/ciris/generic/readers/GenericConfigReaders.scala b/modules/generic/jvm/src/main/scala/ciris/generic/readers/GenericConfigReaders.scala new file mode 100644 index 00000000..253d9e30 --- /dev/null +++ b/modules/generic/jvm/src/main/scala/ciris/generic/readers/GenericConfigReaders.scala @@ -0,0 +1,41 @@ +package ciris.generic.readers + +import ciris.{ConfigError, ConfigReader} +import shapeless.{:+:, ::, CNil, Coproduct, Generic, HNil, Inl, Inr, Lazy} + +trait GenericConfigReaders { + implicit val cNilConfigReader: ConfigReader[CNil] = + ConfigReader.pure { (key, source) ⇒ + Left(ConfigError( + s"Could not find any valid coproduct choice while reading ${source.keyType.value} [$key]")) + } + + implicit def coproductConfigReader[A, B <: Coproduct]( + implicit readA: Lazy[ConfigReader[A]], + readB: ConfigReader[B] + ): ConfigReader[A :+: B] = { + ConfigReader.pure { (key, source) ⇒ + readA.value.read(key)(source) match { + case Right(a) ⇒ Right(Inl(a)) + case Left(aError) ⇒ + readB.read(key)(source) match { + case Right(b) ⇒ Right(Inr(b)) + case Left(bError) ⇒ Left(aError combine bError) + } + } + } + } + + implicit def hListArityOneConfigReader[A]( + implicit readA: Lazy[ConfigReader[A]] + ): ConfigReader[A :: HNil] = { + readA.value.map(_ :: HNil) + } + + implicit def genericConfigReader[A, B]( + implicit gen: Generic.Aux[A, B], + readB: Lazy[ConfigReader[B]] + ): ConfigReader[A] = { + readB.value.map(gen.from) + } +} diff --git a/modules/generic/jvm/src/test/scala/ciris/generic/readers/GenericConfigReadersSpec.scala b/modules/generic/jvm/src/test/scala/ciris/generic/readers/GenericConfigReadersSpec.scala new file mode 100644 index 00000000..a7c89664 --- /dev/null +++ b/modules/generic/jvm/src/test/scala/ciris/generic/readers/GenericConfigReadersSpec.scala @@ -0,0 +1,97 @@ +package ciris.generic.readers + +import ciris.PropertySpec +import ciris.generic._ +import shapeless._ + +final case class Port(value: Int) extends AnyVal + +sealed trait DoubleOrBoolean +final case class DoubleValue(value: Double) extends DoubleOrBoolean +final case class BooleanValue(value: Boolean) extends DoubleOrBoolean + +final case class IntValue(value: Int) + +final class GenericConfigReadersSpec extends PropertySpec { + "ShapelessConfigReaders" when { + "reading value classes" should { + "successfully read value classes" in { + forAll { value: Int ⇒ + readValue[Port](value.toString) shouldBe Right(Port(value)) + } + } + + "return a failure for the wrong type" in { + forAll { value: String ⇒ + whenever(fails(value.toInt)) { + readValue[Port](value) shouldBe a[Left[_, _]] + } + } + } + } + + "reading coproducts" should { + type DoubleOrBooleanCoproduct = DoubleValue :+: BooleanValue :+: CNil + + "successfully read coproduct values" in { + forAll { double: Double ⇒ + readValue[DoubleOrBooleanCoproduct](double.toString) shouldBe + Right(Coproduct[DoubleOrBooleanCoproduct](DoubleValue(double))) + } + + forAll { boolean: Boolean ⇒ + readValue[DoubleOrBooleanCoproduct](boolean.toString) shouldBe + Right(Coproduct[DoubleOrBooleanCoproduct](BooleanValue(boolean))) + } + } + + "return a failure for wrong coproduct values" in { + forAll { string: String ⇒ + whenever(fails(string.toDouble)) { + whenever(fails(string.toBoolean)) { + readValue[DoubleOrBooleanCoproduct](string) shouldBe a[Left[_, _]] + } + } + } + } + + "successfully read generic coproduct values" in { + forAll { double: Double ⇒ + readValue[DoubleOrBoolean](double.toString) shouldBe + Right(DoubleValue(double)) + } + + forAll { boolean: Boolean ⇒ + readValue[DoubleOrBoolean](boolean.toString) shouldBe + Right(BooleanValue(boolean)) + } + } + + "return a failure for wrong generic coproduct values" in { + forAll { string: String ⇒ + whenever(fails(string.toDouble)) { + whenever(fails(string.toBoolean)) { + readValue[DoubleOrBoolean](string) shouldBe a[Left[_, _]] + } + } + } + } + } + + "reading products with arity one" should { + "successfully read product values" in { + forAll { int: Int ⇒ + readValue[IntValue](int.toString) shouldBe Right(IntValue(int)) + } + } + + "return a failure for wrong product values" in { + forAll { string: String ⇒ + whenever(fails(string.toInt)) { + readValue[IntValue](string) shouldBe a[Left[_, _]] + } + } + } + } + } +}