4
votes

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
  }
}
1

1 Answers

4
votes

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 Strings 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)
}