Scala circe deriveUnwrapped value class doesn't work for missing member

933 views Asked by At

I am trying to decode a String value class in which if the string is empty I need to get a None otherwise a Some. I have the following ammonite script example:

import $ivy.`io.circe::circe-generic:0.13.0`, io.circe._, io.circe.generic.auto._, io.circe.syntax._, io.circe.generic.JsonCodec
import $ivy.`io.circe::circe-generic-extras:0.13.0`, io.circe.generic.extras._, io.circe.generic.extras.semiauto._
import $ivy.`io.circe::circe-parser:0.13.0`, io.circe.parser._

final case class CustomString(value: Option[String]) extends AnyVal
final case class TestString(name: CustomString)

implicit val customStringDecoder: Decoder[CustomString] =
    deriveUnwrappedDecoder[CustomString].map(ss => CustomString(ss.value.flatMap(s => Option.when(s.nonEmpty)(s))))

implicit val customStringEncoder: Encoder[CustomString] = deriveUnwrappedEncoder[CustomString]
implicit val testStringCodec: Codec[TestString] = io.circe.generic.semiauto.deriveCodec

val testString = TestString(CustomString(Some("test")))
val emptyTestString = TestString(CustomString(Some("")))
val noneTestString = TestString(CustomString(None))
val nullJson = """{"name":null}"""
val emptyJson = """{}"""

assert(testString.asJson.noSpaces == """{"name":"test"}""")
assert(emptyTestString.asJson.noSpaces == """{"name":""}""")
assert(noneTestString.asJson.noSpaces == nullJson)
assert(noneTestString.asJson.dropNullValues.noSpaces == emptyJson)

assert(decode[TestString](nullJson).exists(_ == noneTestString)) // this passes
assert(decode[TestString](emptyJson).exists(_ == noneTestString)) // this fails
3

There are 3 answers

0
Mihai Soloi On BEST ANSWER

The answers that exist don't solve the problem so here's the solution. If you don't want to use refined, you can define the decoder like so:

implicit val customStringDecoder: Decoder[CustomString] =
  Decoder
    .decodeOption(deriveUnwrappedDecoder[CustomString])
    .map(ssOpt => CustomString(ssOpt.flatMap(_.value.flatMap(s => Option.when(s.nonEmpty)(s)))))

However, if you use refined types (which I recommend) it can be even simpler by using the circe-refined and it comes with the benefit of better type safety(i.e. you know that your String is not empty). Here's the complete ammonite script for testing:

import $ivy.`io.circe::circe-generic:0.13.0`, io.circe._, io.circe.generic.auto._, io.circe.syntax._
import $ivy.`io.circe::circe-parser:0.13.0`, io.circe.parser._

import $ivy.`eu.timepit::refined:0.9.14`, eu.timepit.refined.types.string.NonEmptyString
import $ivy.`io.circe::circe-refined:0.13.0`, io.circe.refined._

final case class TestString(name: Option[NonEmptyString])

implicit val customNonEmptyStringDecoder: Decoder[Option[NonEmptyString]] =
    Decoder[Option[String]].map(_.flatMap(NonEmptyString.unapply))

val testString = TestString(NonEmptyString.unapply("test"))
val emptyTestString = TestString(NonEmptyString.unapply(""))
val noneTestString = TestString(None)
val nullJson = """{"name":null}"""
val emptyJson = """{}"""
val emptyStringJson = """{"name":""}"""

assert(testString.asJson.noSpaces == """{"name":"test"}""")
assert(noneTestString.asJson.noSpaces == nullJson)
assert(noneTestString.asJson.dropNullValues.noSpaces == emptyJson)


assert(decode[TestString](nullJson).exists(_ == noneTestString))
assert(decode[TestString](emptyJson).exists(_ == noneTestString))
assert(decode[TestString](emptyStringJson).exists(_ == noneTestString))
2
Frederick Roth On

As far as I know there is no automated feature for this.

I would solve it by using the circe cursor api directly:

import $ivy.`io.circe::circe-generic:0.13.0`, io.circe._, io.circe.generic.auto._, io.circe.syntax._, io.circe.generic.JsonCodec
import $ivy.`io.circe::circe-generic-extras:0.13.0`, io.circe.generic.extras._, io.circe.generic.extras.semiauto._
import $ivy.`io.circe::circe-parser:0.13.0`, io.circe.parser._

final case class CustomString(value: Option[String]) extends AnyVal
final case class TestString(name: CustomString)

implicit val testStringDecoder: Decoder[TestString] =  (c: HCursor) =>{
     c.downField("name").as[Option[String]].map(string => TestString(CustomString(string)))
}

implicit val customStringEncoder: Encoder[CustomString] = deriveUnwrappedEncoder[CustomString]
implicit val testStringCodec: Encoder[TestString] = io.circe.generic.semiauto.deriveEncoder

val testString = TestString(CustomString(Some("test")))
val emptyTestString = TestString(CustomString(Some("")))
val noneTestString = TestString(CustomString(None))
val nullJson = """{"name":null}"""
val emptyJson = """{}"""

assert(testString.asJson.noSpaces == """{"name":"test"}""")
assert(emptyTestString.asJson.noSpaces == """{"name":""}""")
assert(noneTestString.asJson.noSpaces == nullJson)
assert(noneTestString.asJson.dropNullValues.noSpaces == emptyJson)

assert(decode[TestString](nullJson).exists(_ == noneTestString)) // this passes
assert(decode[TestString](emptyJson).exists(_ == noneTestString)) // this fails
1
Radu Gancea On

You could alternatevely go for a different encoding so the intent is more clear and you wouldn't need to pattern match on the nested case class when you need to use the string.

final case class TestString(name: Option[NonEmptyString])
object TestString {
  implicit val decoder: Decoder[TestString] = deriveDecoder
}

sealed trait NonEmptyString {
  def value: String
}
object NonEmptyString {
  private case class NonEmptyStringImpl(value: String) extends NonEmptyString

  def apply(value: String): Either[NonEmptyStringRequiredException, NonEmptyString] = {
    if (value.nonEmpty) Right(NonEmptyStringImpl(value))
    else Left(new NonEmptyStringRequiredException)
  }

  implicit val encoder: Encoder[NonEmptyString] = Encoder[String].contramap(_.value)

  implicit val decoder: Decoder[Option[NonEmptyString]] = Decoder.withReattempt {
    case h: HCursor =>
      if (h.value.isNull) Right(None)
      else h.value.asString match {
        case Some(string) => Right(apply(string).toOption)
        case None => Left(DecodingFailure("Not a string.", h.history))
      }
    case _: FailedCursor =>
      Right(None)
  }
}