Reputation: 73
I'm reading in query parameters and converting them into a Map[Symbol, String]
. I would like to add some type safety to these query parameters through a set of case classes.
These case classes will be different depending on the incoming http request, so this needs to support different case classes.
If the incoming query parameters don't match the defined case class
the Parser
should return None
.
I have attempted to use shapeless to implement a generic parser. It work's if all of the parameters are of type String
. But I need to support any type of query parameter.
I've tried to incorporate the implicit conversion logic seen in this post, but unable to get it working. https://meta.plasm.us/posts/2015/11/08/type-classes-and-generic-derivation/ (new to shapeless)
Existing Parser
(without string to type conversion):
class Parser[A] {
def from[R <: HList]
(m: Map[Symbol, String])
(implicit
gen: LabelledGeneric.Aux[A, R],
fromMap: FromMap[R]
): Option[A] = fromMap(m).map(gen.from)
}
object Parser {
def to[A]: Parser[A] = new Parser[A]
}
Tests describing issue:
class ParserSpec extends FlatSpec with Matchers {
private val sampleName: String = "Bob"
private val sampleVersion: Int = 1
//Partial Solution
case class QueryParams(name: String, version: String)
//Full Solution (not working)
case class QueryParams2(name: String, version: Int)
"A Parser" should "parse query parameters from a map with only string values" in {
val mapOfQueryParams = Map('name -> sampleName, 'version -> sampleVersion.toString)
val result = Parser.to[QueryParams].from(mapOfQueryParams)
result shouldBe 'defined
result.get.name shouldEqual sampleName
result.get.version shouldEqual sampleVersion.toString
}
it should "parse query parameters from a map with any type of value" in {
val mapOfQueryParams = Map('name -> sampleName, 'version -> sampleVersion.toString)
val result = Parser.to[QueryParams2].from(mapOfQueryParams)
//result is not defined as it's not able to convert a string to integer
result shouldBe 'defined
result.get.name shouldEqual sampleName
result.get.version shouldEqual sampleVersion
}
}
Upvotes: 4
Views: 352
Reputation: 14224
FromMap
uses shapeless.Typeable
to convert values to the expected type. So the easiest way to make your code work is to define an instance of Typeable
to convert from String
to Int
(and additional Typeable
instances for any value type, that appears in your case classes):
implicit val stringToInt: Typeable[Int] = new Typeable[Int] {
override def cast(t: Any): Option[Int] = t match {
case t: String => Try(t.toInt).toOption
case _ => Typeable.intTypeable.cast(t)
}
override def describe: String = "Int from String"
}
This is however not an intended use of Typeable
, which is designed to confirm that a variable with type Any
is already an instance of the expected type without any conversion. In other words it's intended to be a typesafe implementation of asInstanceOf
, that can also work around type erasure.
For correctness you can define your own ReadFromMap
typeclass, that uses your own Read
typeclass for conversion from String
s to the expected types. Here is a simple implementation of the Read
typeclass (assuming Scala 2.12):
import scala.util.Try
trait Read[T] {
def apply(string: String): Option[T]
}
object Read {
implicit val readString: Read[String] = Some(_)
implicit val readInt: Read[Int] = s => Try(s.toInt).toOption
// Add more implicits for other types in your case classes
}
And you can copy and adapt the implementation of FromMap
to use this Read
typeclass:
import shapeless._
import shapeless.labelled._
trait ReadFromMap[R <: HList] extends Serializable {
def apply(map: Map[Symbol, String]): Option[R]
}
object ReadFromMap {
implicit def hnil: ReadFromMap[HNil] = _ => Some(HNil)
implicit def hlist[K <: Symbol, V, T <: HList](implicit
keyWitness: Witness.Aux[K],
readValue: Read[V],
readRest: ReadFromMap[T]
): ReadFromMap[FieldType[K, V] :: T] = map => for {
value <- map.get(keyWitness.value)
converted <- readValue(value)
rest <- readRest(map)
} yield field[K](converted) :: rest
}
Then simply use this new typeclass in your Parser
:
class Parser[A] {
def from[R <: HList]
(m: Map[Symbol, String])
(implicit
gen: LabelledGeneric.Aux[A, R],
fromMap: ReadFromMap[R]
): Option[A] = fromMap(m).map(gen.from)
}
Upvotes: 4